diff --git a/app/assets/javascripts/markdown-it-bundle.js b/app/assets/javascripts/markdown-it-bundle.js index dcacaf1748d..3460c3a6027 100644 --- a/app/assets/javascripts/markdown-it-bundle.js +++ b/app/assets/javascripts/markdown-it-bundle.js @@ -1,15 +1,15 @@ //= require markdown-it.js -//= require ./pretty-text/engines/discourse-markdown/helpers -//= require ./pretty-text/engines/discourse-markdown/mentions -//= require ./pretty-text/engines/discourse-markdown/quotes -//= require ./pretty-text/engines/discourse-markdown/emoji -//= require ./pretty-text/engines/discourse-markdown/onebox -//= require ./pretty-text/engines/discourse-markdown/bbcode-block -//= require ./pretty-text/engines/discourse-markdown/bbcode-inline -//= require ./pretty-text/engines/discourse-markdown/code -//= require ./pretty-text/engines/discourse-markdown/category-hashtag -//= require ./pretty-text/engines/discourse-markdown/censored -//= require ./pretty-text/engines/discourse-markdown/table -//= require ./pretty-text/engines/discourse-markdown/paragraph -//= require ./pretty-text/engines/discourse-markdown/newline -//= require ./pretty-text/engines/discourse-markdown/html_img +//= require ./pretty-text/engines/markdown-it/helpers +//= require ./pretty-text/engines/markdown-it/mentions +//= require ./pretty-text/engines/markdown-it/quotes +//= require ./pretty-text/engines/markdown-it/emoji +//= require ./pretty-text/engines/markdown-it/onebox +//= require ./pretty-text/engines/markdown-it/bbcode-block +//= require ./pretty-text/engines/markdown-it/bbcode-inline +//= require ./pretty-text/engines/markdown-it/code +//= require ./pretty-text/engines/markdown-it/category-hashtag +//= require ./pretty-text/engines/markdown-it/censored +//= require ./pretty-text/engines/markdown-it/table +//= require ./pretty-text/engines/markdown-it/paragraph +//= require ./pretty-text/engines/markdown-it/newline +//= require ./pretty-text/engines/markdown-it/html_img diff --git a/app/assets/javascripts/pretty-text-bundle.js b/app/assets/javascripts/pretty-text-bundle.js index 869631eda19..691fad8e9a5 100644 --- a/app/assets/javascripts/pretty-text-bundle.js +++ b/app/assets/javascripts/pretty-text-bundle.js @@ -3,8 +3,11 @@ //= require ./pretty-text/censored-words //= require ./pretty-text/emoji/data //= require ./pretty-text/emoji +//= require ./pretty-text/engines/discourse-markdown //= require ./pretty-text/engines/discourse-markdown-it +//= require_tree ./pretty-text/engines/discourse-markdown //= require xss.min +//= require better_markdown.js //= require ./pretty-text/xss //= require ./pretty-text/white-lister //= require ./pretty-text/sanitizer 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 5ff68ef7959..ac80029cfdb 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 @@ -151,7 +151,7 @@ export function setup(opts, siteSettings, state) { } // we got to require this late cause bundle is not loaded in pretty-text - Helpers = Helpers || requirejs('pretty-text/engines/discourse-markdown/helpers'); + Helpers = Helpers || requirejs('pretty-text/engines/markdown-it/helpers'); opts.markdownIt = true; diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/auto-link.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/auto-link.js.es6 new file mode 100644 index 00000000000..78ebe441fd6 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/auto-link.js.es6 @@ -0,0 +1,27 @@ +// This addition handles auto linking of text. When included, it will parse out links and create +// ``s for them. + +const urlReplacerArgs = { + matcher: /^((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/, + spaceOrTagBoundary: true, + + emitter(matches) { + const url = matches[1]; + let href = url; + + // Don't autolink a markdown link to something + if (url.match(/\]\[\d$/)) { return; } + + // If we improperly caught a markdown link abort + if (url.match(/\(http/)) { return; } + + if (url.match(/^www/)) { href = "http://" + url; } + return ['a', { href }, url]; + } +}; + +export function setup(helper) { + if (helper.markdownIt) { return; } + helper.inlineRegexp(_.merge({start: 'http'}, urlReplacerArgs)); + helper.inlineRegexp(_.merge({start: 'www'}, urlReplacerArgs)); +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode.js.es6 new file mode 100644 index 00000000000..d0118497523 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode.js.es6 @@ -0,0 +1,170 @@ +export function register(helper, codeName, args, emitter) { + // Optional second param for args + if (typeof args === "function") { + emitter = args; + args = {}; + } + + helper.replaceBlock({ + start: new RegExp("\\[" + codeName + "(=[^\\[\\]]+)?\\]([\\s\\S]*)", "igm"), + stop: new RegExp("\\[\\/" + codeName + "\\]", "igm"), + emitter(blockContents, matches) { + + + const options = helper.getOptions(); + while (blockContents.length && (typeof blockContents[0] === "string" || blockContents[0] instanceof String)) { + blockContents[0] = String(blockContents[0]).replace(/^\s+/, ''); + if (!blockContents[0].length) { + blockContents.shift(); + } else { + break; + } + } + + let contents = []; + if (blockContents.length) { + const nextContents = blockContents.slice(1); + blockContents = this.processBlock(blockContents[0], nextContents); + + nextContents.forEach(nc => { + blockContents = blockContents.concat(this.processBlock(nc, [])); + }); + + blockContents.forEach(bc => { + if (typeof bc === "string" || bc instanceof String) { + var processed = this.processInline(String(bc)); + if (processed.length) { + contents.push(['p'].concat(processed)); + } + } else { + contents.push(bc); + } + }); + } + if (!args.singlePara && contents.length === 1 && contents[0] instanceof Array && contents[0][0] === "para") { + contents[0].shift(); + contents = contents[0]; + } + const result = emitter(contents, matches[1] ? matches[1].replace(/^=|\"/g, '') : null, options); + return args.noWrap ? result : ['p', result]; + } + }); +}; + +export function builders(helper) { + function replaceBBCode(tag, emitter, opts) { + const start = `[${tag}]`; + const stop = `[/${tag}]`; + + opts = opts || {}; + opts = _.merge(opts, { start, stop, emitter }); + helper.inlineBetween(opts); + + opts = _.merge(opts, { start: start.toUpperCase(), stop: stop.toUpperCase(), emitter }); + helper.inlineBetween(opts); + } + + return { + replaceBBCode, + + register(codeName, args, emitter) { + register(helper, codeName, args, emitter); + }, + + rawBBCode(tag, emitter) { + replaceBBCode(tag, emitter, { rawContents: true }); + }, + + removeEmptyLines(contents) { + const result = []; + for (let i=0; i < contents.length; i++) { + if (contents[i] !== "\n") { result.push(contents[i]); } + } + return result; + }, + + replaceBBCodeParamsRaw(tag, emitter) { + var opts = { + rawContents: true, + emitter(contents) { + const m = /^([^\]]+)\]([\S\s]*)$/.exec(contents); + if (m) { return emitter.call(this, m[1], m[2]); } + } + }; + + helper.inlineBetween(_.merge(opts, { start: "[" + tag + "=", stop: "[/" + tag + "]" })); + + tag = tag.toUpperCase(); + helper.inlineBetween(_.merge(opts, { start: "[" + tag + "=", stop: "[/" + tag + "]" })); + } + }; +} + +export function setup(helper) { + + if (helper.markdownIt) { return; } + + helper.whiteList(['span.bbcode-b', 'span.bbcode-i', 'span.bbcode-u', 'span.bbcode-s']); + + const { replaceBBCode, rawBBCode, removeEmptyLines, replaceBBCodeParamsRaw } = builders(helper); + + replaceBBCode('b', contents => ['span', {'class': 'bbcode-b'}].concat(contents)); + replaceBBCode('i', contents => ['span', {'class': 'bbcode-i'}].concat(contents)); + replaceBBCode('u', contents => ['span', {'class': 'bbcode-u'}].concat(contents)); + replaceBBCode('s', contents => ['span', {'class': 'bbcode-s'}].concat(contents)); + + replaceBBCode('ul', contents => ['ul'].concat(removeEmptyLines(contents))); + replaceBBCode('ol', contents => ['ol'].concat(removeEmptyLines(contents))); + replaceBBCode('li', contents => ['li'].concat(removeEmptyLines(contents))); + + rawBBCode('img', href => ['img', {href}]); + rawBBCode('email', contents => ['a', {href: "mailto:" + contents, 'data-bbcode': true}, contents]); + + replaceBBCode('url', contents => { + if (!Array.isArray(contents)) { return; } + + const first = contents[0]; + if (contents.length === 1 && Array.isArray(first) && first[0] === 'a') { + // single-line bbcode links shouldn't be oneboxed, so we mark this as a bbcode link. + if (typeof first[1] !== 'object') { first.splice(1, 0, {}); } + first[1]['data-bbcode'] = true; + } + return ['concat'].concat(contents); + }); + + replaceBBCodeParamsRaw('url', function(param, contents) { + const url = param.replace(/(^")|("$)/g, ''); + return ['a', {'href': url}].concat(this.processInline(contents)); + }); + + replaceBBCodeParamsRaw("email", function(param, contents) { + return ['a', {href: "mailto:" + param, 'data-bbcode': true}].concat(contents); + }); + + helper.onParseNode(event => { + if (!Array.isArray(event.node)) { return; } + const result = [event.node[0]]; + const nodes = event.node.slice(1); + for (let i = 0; i < nodes.length; i++) { + if (Array.isArray(nodes[i]) && nodes[i][0] === 'concat') { + for (let j = 1; j < nodes[i].length; j++) { result.push(nodes[i][j]); } + } else { + result.push(nodes[i]); + } + } + for (let i = 0; i < result.length; i++) { event.node[i] = result[i]; } + }); + + helper.replaceBlock({ + start: /(\[code\])([\s\S]*)/igm, + stop: /\[\/code\]/igm, + rawContents: true, + + emitter(blockContents) { + const options = helper.getOptions(); + const inner = blockContents.join("\n"); + const defaultCodeLang = options.defaultCodeLang; + return ['p', ['pre', ['code', {'class': `lang-${defaultCodeLang}`}, inner]]]; + } + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bold-italics.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bold-italics.js.es6 new file mode 100644 index 00000000000..a79a983b08b --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/bold-italics.js.es6 @@ -0,0 +1,73 @@ +import guid from 'pretty-text/guid'; + +/** + markdown-js doesn't ensure that em/strong codes are present on word boundaries. + So we create our own handlers here. +**/ + +// From PageDown +const aLetter = /[a-zA-Z0-9\u00aa\u00b5\u00ba\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02c1\u02c6-\u02d1\u02e0-\u02e4\u02ec\u02ee\u0370-\u0374\u0376-\u0377\u037a-\u037d\u0386\u0388-\u038a\u038c\u038e-\u03a1\u03a3-\u03f5\u03f7-\u0481\u048a-\u0523\u0531-\u0556\u0559\u0561-\u0587\u05d0-\u05ea\u05f0-\u05f2\u0621-\u064a\u0660-\u0669\u066e-\u066f\u0671-\u06d3\u06d5\u06e5-\u06e6\u06ee-\u06fc\u06ff\u0710\u0712-\u072f\u074d-\u07a5\u07b1\u07c0-\u07ea\u07f4-\u07f5\u07fa\u0904-\u0939\u093d\u0950\u0958-\u0961\u0966-\u096f\u0971-\u0972\u097b-\u097f\u0985-\u098c\u098f-\u0990\u0993-\u09a8\u09aa-\u09b0\u09b2\u09b6-\u09b9\u09bd\u09ce\u09dc-\u09dd\u09df-\u09e1\u09e6-\u09f1\u0a05-\u0a0a\u0a0f-\u0a10\u0a13-\u0a28\u0a2a-\u0a30\u0a32-\u0a33\u0a35-\u0a36\u0a38-\u0a39\u0a59-\u0a5c\u0a5e\u0a66-\u0a6f\u0a72-\u0a74\u0a85-\u0a8d\u0a8f-\u0a91\u0a93-\u0aa8\u0aaa-\u0ab0\u0ab2-\u0ab3\u0ab5-\u0ab9\u0abd\u0ad0\u0ae0-\u0ae1\u0ae6-\u0aef\u0b05-\u0b0c\u0b0f-\u0b10\u0b13-\u0b28\u0b2a-\u0b30\u0b32-\u0b33\u0b35-\u0b39\u0b3d\u0b5c-\u0b5d\u0b5f-\u0b61\u0b66-\u0b6f\u0b71\u0b83\u0b85-\u0b8a\u0b8e-\u0b90\u0b92-\u0b95\u0b99-\u0b9a\u0b9c\u0b9e-\u0b9f\u0ba3-\u0ba4\u0ba8-\u0baa\u0bae-\u0bb9\u0bd0\u0be6-\u0bef\u0c05-\u0c0c\u0c0e-\u0c10\u0c12-\u0c28\u0c2a-\u0c33\u0c35-\u0c39\u0c3d\u0c58-\u0c59\u0c60-\u0c61\u0c66-\u0c6f\u0c85-\u0c8c\u0c8e-\u0c90\u0c92-\u0ca8\u0caa-\u0cb3\u0cb5-\u0cb9\u0cbd\u0cde\u0ce0-\u0ce1\u0ce6-\u0cef\u0d05-\u0d0c\u0d0e-\u0d10\u0d12-\u0d28\u0d2a-\u0d39\u0d3d\u0d60-\u0d61\u0d66-\u0d6f\u0d7a-\u0d7f\u0d85-\u0d96\u0d9a-\u0db1\u0db3-\u0dbb\u0dbd\u0dc0-\u0dc6\u0e01-\u0e30\u0e32-\u0e33\u0e40-\u0e46\u0e50-\u0e59\u0e81-\u0e82\u0e84\u0e87-\u0e88\u0e8a\u0e8d\u0e94-\u0e97\u0e99-\u0e9f\u0ea1-\u0ea3\u0ea5\u0ea7\u0eaa-\u0eab\u0ead-\u0eb0\u0eb2-\u0eb3\u0ebd\u0ec0-\u0ec4\u0ec6\u0ed0-\u0ed9\u0edc-\u0edd\u0f00\u0f20-\u0f29\u0f40-\u0f47\u0f49-\u0f6c\u0f88-\u0f8b\u1000-\u102a\u103f-\u1049\u1050-\u1055\u105a-\u105d\u1061\u1065-\u1066\u106e-\u1070\u1075-\u1081\u108e\u1090-\u1099\u10a0-\u10c5\u10d0-\u10fa\u10fc\u1100-\u1159\u115f-\u11a2\u11a8-\u11f9\u1200-\u1248\u124a-\u124d\u1250-\u1256\u1258\u125a-\u125d\u1260-\u1288\u128a-\u128d\u1290-\u12b0\u12b2-\u12b5\u12b8-\u12be\u12c0\u12c2-\u12c5\u12c8-\u12d6\u12d8-\u1310\u1312-\u1315\u1318-\u135a\u1380-\u138f\u13a0-\u13f4\u1401-\u166c\u166f-\u1676\u1681-\u169a\u16a0-\u16ea\u1700-\u170c\u170e-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176c\u176e-\u1770\u1780-\u17b3\u17d7\u17dc\u17e0-\u17e9\u1810-\u1819\u1820-\u1877\u1880-\u18a8\u18aa\u1900-\u191c\u1946-\u196d\u1970-\u1974\u1980-\u19a9\u19c1-\u19c7\u19d0-\u19d9\u1a00-\u1a16\u1b05-\u1b33\u1b45-\u1b4b\u1b50-\u1b59\u1b83-\u1ba0\u1bae-\u1bb9\u1c00-\u1c23\u1c40-\u1c49\u1c4d-\u1c7d\u1d00-\u1dbf\u1e00-\u1f15\u1f18-\u1f1d\u1f20-\u1f45\u1f48-\u1f4d\u1f50-\u1f57\u1f59\u1f5b\u1f5d\u1f5f-\u1f7d\u1f80-\u1fb4\u1fb6-\u1fbc\u1fbe\u1fc2-\u1fc4\u1fc6-\u1fcc\u1fd0-\u1fd3\u1fd6-\u1fdb\u1fe0-\u1fec\u1ff2-\u1ff4\u1ff6-\u1ffc\u203f-\u2040\u2054\u2071\u207f\u2090-\u2094\u2102\u2107\u210a-\u2113\u2115\u2119-\u211d\u2124\u2126\u2128\u212a-\u212d\u212f-\u2139\u213c-\u213f\u2145-\u2149\u214e\u2183-\u2184\u2c00-\u2c2e\u2c30-\u2c5e\u2c60-\u2c6f\u2c71-\u2c7d\u2c80-\u2ce4\u2d00-\u2d25\u2d30-\u2d65\u2d6f\u2d80-\u2d96\u2da0-\u2da6\u2da8-\u2dae\u2db0-\u2db6\u2db8-\u2dbe\u2dc0-\u2dc6\u2dc8-\u2dce\u2dd0-\u2dd6\u2dd8-\u2dde\u2e2f\u3005-\u3006\u3031-\u3035\u303b-\u303c\u3041-\u3096\u309d-\u309f\u30a1-\u30fa\u30fc-\u30ff\u3105-\u312d\u3131-\u318e\u31a0-\u31b7\u31f0-\u31ff\u3400-\u4db5\u4e00-\u9fc3\ua000-\ua48c\ua500-\ua60c\ua610-\ua62b\ua640-\ua65f\ua662-\ua66e\ua67f-\ua697\ua717-\ua71f\ua722-\ua788\ua78b-\ua78c\ua7fb-\ua801\ua803-\ua805\ua807-\ua80a\ua80c-\ua822\ua840-\ua873\ua882-\ua8b3\ua8d0-\ua8d9\ua900-\ua925\ua930-\ua946\uaa00-\uaa28\uaa40-\uaa42\uaa44-\uaa4b\uaa50-\uaa59\uac00-\ud7a3\uf900-\ufa2d\ufa30-\ufa6a\ufa70-\ufad9\ufb00-\ufb06\ufb13-\ufb17\ufb1d\ufb1f-\ufb28\ufb2a-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufbb1\ufbd3-\ufd3d\ufd50-\ufd8f\ufd92-\ufdc7\ufdf0-\ufdfb\ufe33-\ufe34\ufe4d-\ufe4f\ufe70-\ufe74\ufe76-\ufefc\uff10-\uff19\uff21-\uff3a\uff3f\uff41-\uff5a\uff66-\uffbe\uffc2-\uffc7\uffca-\uffcf\uffd2-\uffd7\uffda-\uffdc]/; + + +function unhoist(obj,from,to){ + let unhoisted = 0; + const regex = new RegExp(from, "g"); + + if(_.isArray(obj)){ + for (let i=0; i= 0) { + const newText = this.processInline(text.substring(match.length, finish+1)); + const unhoisted_length = unhoist(newText,hash,match[0]); + const array = typeof tag === "string" ? [tag].concat(newText) : [tag[0], [tag[1]].concat(newText)]; + return [(finish + match.length + 1) - unhoisted_length, array]; + } + }); + } + + replaceMarkdown('***', ['strong','em']); + replaceMarkdown('___', ['strong','em']); + replaceMarkdown('**', 'strong'); + replaceMarkdown('__', 'strong'); + replaceMarkdown('*', 'em'); + replaceMarkdown('_', 'em'); +}; diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/category-hashtag.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/category-hashtag.js.es6 index 79d57002a68..c33eeff65f6 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/category-hashtag.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/category-hashtag.js.es6 @@ -1,104 +1,20 @@ -function addHashtag(buffer, matches, state) { - const options = state.md.options.discourse; - const [hashtag, slug] = matches; - const categoryHashtagLookup = options.categoryHashtagLookup; - const result = categoryHashtagLookup && categoryHashtagLookup(slug); - - let token; - - if (result) { - token = new state.Token('link_open', 'a', 1); - token.attrs = [['class', 'hashtag'], ['href', result[0]]]; - token.block = false; - buffer.push(token); - - token = new state.Token('text', '', 0); - token.content = '#'; - buffer.push(token); - - token = new state.Token('span_open', 'span', 1); - token.block = false; - buffer.push(token); - - token = new state.Token('text', '', 0); - token.content = result[1]; - buffer.push(token); - - buffer.push(new state.Token('span_close', 'span', -1)); - - buffer.push(new state.Token('link_close', 'a', -1)); - } else { - - token = new state.Token('span_open', 'span', 1); - token.attrs = [['class', 'hashtag']]; - buffer.push(token); - - token = new state.Token('text', '', 0); - token.content = hashtag; - buffer.push(token); - - token = new state.Token('span_close', 'span', -1); - buffer.push(token); - } -} - -const REGEX = /#([\w-:]{1,101})/gi; - -function allowedBoundary(content, index, utils) { - let code = content.charCodeAt(index); - return (utils.isWhiteSpace(code) || utils.isPunctChar(String.fromCharCode(code))); -} - -function applyHashtag(content, state) { - let result = null, - match, - pos = 0; - - while (match = REGEX.exec(content)) { - // check boundary - if (match.index > 0) { - if (!allowedBoundary(content, match.index-1, state.md.utils)) { - continue; - } - } - - // check forward boundary as well - if (match.index + match[0].length < content.length) { - if (!allowedBoundary(content, match.index + match[0].length, state.md.utils)) { - continue; - } - } - - if (match.index > pos) { - result = result || []; - let token = new state.Token('text', '', 0); - token.content = content.slice(pos, match.index); - result.push(token); - } - - result = result || []; - addHashtag(result, match, state); - - pos = match.index + match[0].length; - } - - if (result && pos < content.length) { - let token = new state.Token('text', '', 0); - token.content = content.slice(pos); - result.push(token); - } - - return result; -} - export function setup(helper) { - if (!helper.markdownIt) { return; } + if (helper.markdownIt) { return; } - helper.registerPlugin(md=>{ + helper.inlineRegexp({ + start: '#', + matcher: /^#([\w-:]{1,101})/i, + spaceOrTagBoundary: true, - md.core.ruler.push('category-hashtag', state => md.options.discourse.helpers.textReplace( - state, applyHashtag, true /* skip all links */ - )); + emitter(matches) { + const options = helper.getOptions(); + const [hashtag, slug] = matches; + const categoryHashtagLookup = options.categoryHashtagLookup; + const result = categoryHashtagLookup && categoryHashtagLookup(slug); + + return result ? ['a', { class: 'hashtag', href: result[0] }, '#', ["span", {}, result[1]]] + : ['span', { class: 'hashtag' }, hashtag]; + } }); } diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/censored.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/censored.js.es6 index 3d5cb8d9313..d3ed549fe09 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/censored.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/censored.js.es6 @@ -1,44 +1,18 @@ -import { censorFn } from 'pretty-text/censored-words'; +import { censor } from 'pretty-text/censored-words'; +import { registerOption } from 'pretty-text/pretty-text'; -function recurse(tokens, apply) { - let i; - for(i=0;i { - if (token.content) { - token.content = censor(token.content); - } - }); -} +registerOption((siteSettings, opts) => { + opts.features.censored = true; + opts.censoredWords = siteSettings.censored_words; + opts.censoredPattern = siteSettings.censored_pattern; +}); export function setup(helper) { - if (!helper.markdownIt) { return; } + if (helper.markdownIt) { return; } - helper.registerOptions((opts, siteSettings) => { - opts.censoredWords = siteSettings.censored_words; - opts.censoredPattern = siteSettings.censored_pattern; - }); - - helper.registerPlugin(md => { - const words = md.options.discourse.censoredWords; - const patterns = md.options.discourse.censoredPattern; - - if ((words && words.length > 0) || (patterns && patterns.length > 0)) { - const replacement = String.fromCharCode(9632); - const censor = censorFn(words, patterns, replacement); - md.core.ruler.push('censored', state => censorTree(state, censor)); - } + helper.addPreProcessor(text => { + const options = helper.getOptions(); + return censor(text, options.censoredWords, options.censoredPattern); }); } diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/code.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/code.js.es6 index c8d94967a1e..048b27a36d4 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/code.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/code.js.es6 @@ -1,38 +1,27 @@ -// we need a custom renderer for code blocks cause we have a slightly non compliant -// format with special handling for text and so on +import { escape } from 'pretty-text/sanitizer'; +import { registerOption } from 'pretty-text/pretty-text'; +// Support for various code blocks const TEXT_CODE_CLASSES = ["text", "pre", "plain"]; - -function render(tokens, idx, options, env, slf, md) { - let token = tokens[idx], - info = token.info ? md.utils.unescapeAll(token.info) : '', - langName = md.options.discourse.defaultCodeLang, - className, - escapedContent = md.utils.escapeHtml(token.content); - - if (info) { - // strip off any additional languages - info = info.split(/\s+/g)[0]; - } - - const acceptableCodeClasses = md.options.discourse.acceptableCodeClasses; - if (acceptableCodeClasses && info && acceptableCodeClasses.indexOf(info) !== -1) { - langName = info; - } - - className = TEXT_CODE_CLASSES.indexOf(info) !== -1 ? 'lang-nohighlight' : 'lang-' + langName; - - return `
${escapedContent}
\n`; +function codeFlattenBlocks(blocks) { + let result = ""; + blocks.forEach(function(b) { + result += b; + if (b.trailing) { result += b.trailing; } + }); + return result; } -export function setup(helper) { - if (!helper.markdownIt) { return; } +registerOption((siteSettings, opts) => { + opts.features.code = true; + opts.defaultCodeLang = siteSettings.default_code_lang; + opts.acceptableCodeClasses = (siteSettings.highlighted_languages || "").split("|").concat(['auto', 'nohighlight']); +}); - helper.registerOptions((opts, siteSettings) => { - opts.defaultCodeLang = siteSettings.default_code_lang; - opts.acceptableCodeClasses = (siteSettings.highlighted_languages || "").split("|").concat(['auto', 'nohighlight']); - }); +export function setup(helper) { + + if (helper.markdownIt) { return; } helper.whiteList({ custom(tag, name, value) { @@ -45,7 +34,50 @@ export function setup(helper) { } }); - helper.registerPlugin(md=>{ - md.renderer.rules.fence = (tokens,idx,options,env,slf)=>render(tokens,idx,options,env,slf,md); + helper.replaceBlock({ + start: /^`{3}([^\n\[\]]+)?\n?([\s\S]*)?/gm, + stop: /^```$/gm, + withoutLeading: /\[quote/gm, //if leading text contains a quote this should not match + emitter(blockContents, matches) { + const opts = helper.getOptions(); + + let codeLang = opts.defaultCodeLang; + const acceptableCodeClasses = opts.acceptableCodeClasses; + if (acceptableCodeClasses && matches[1] && acceptableCodeClasses.indexOf(matches[1]) !== -1) { + codeLang = matches[1]; + } + + if (TEXT_CODE_CLASSES.indexOf(matches[1]) !== -1) { + return ['p', ['pre', ['code', {'class': 'lang-nohighlight'}, codeFlattenBlocks(blockContents) ]]]; + } else { + return ['p', ['pre', ['code', {'class': 'lang-' + codeLang}, codeFlattenBlocks(blockContents) ]]]; + } + } + }); + + helper.replaceBlock({ + start: /(]*\>)([\s\S]*)/igm, + stop: /<\/pre>/igm, + rawContents: true, + skipIfTradtionalLinebreaks: true, + + emitter(blockContents) { + return ['p', ['pre', codeFlattenBlocks(blockContents)]]; + } + }); + + // Ensure that content in a code block is fully escaped. This way it's not white listed + // and we can use HTML and Javascript examples. + helper.onParseNode(function(event) { + const node = event.node, + path = event.path; + + if (node[0] === 'code') { + const regexp = (path && path[path.length-1] && path[path.length-1][0] && path[path.length-1][0] === "pre") ? + / +$/g : /^ +| +$/g; + + const contents = node[node.length-1]; + node[node.length-1] = escape(contents.replace(regexp,'')); + } }); } diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js.es6 index 0e4eed203bf..60864a2263b 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/emoji.js.es6 @@ -1,246 +1,117 @@ +import { registerOption } from 'pretty-text/pretty-text'; import { buildEmojiUrl, isCustomEmoji } from 'pretty-text/emoji'; import { translations } from 'pretty-text/emoji/data'; -const MAX_NAME_LENGTH = 60; +let _unicodeReplacements; +let _unicodeRegexp; +export function setUnicodeReplacements(replacements) { + _unicodeReplacements = replacements; + if (replacements) { + // We sort and reverse to match longer emoji sequences first + _unicodeRegexp = new RegExp(Object.keys(replacements).sort().reverse().join("|"), "g"); + } +}; -let translationTree = null; - -// This allows us to efficiently search for aliases -// We build a data structure that allows us to quickly -// search through our N next chars to see if any match -// one of our alias emojis. -// -function buildTranslationTree() { - let tree = []; - let lastNode; - - Object.keys(translations).forEach(function(key){ - let i; - let node = tree; - - for(i=0;i 0) { - let prev = content.charCodeAt(pos-1); - if (!state.md.utils.isSpace(prev) && !state.md.utils.isPunctChar(String.fromCharCode(prev))) { - return; - } - } - - pos++; - if (content.charCodeAt(pos) === 58) { - return; - } - - let length = 0; - while(length < MAX_NAME_LENGTH) { - length++; - - if (content.charCodeAt(pos+length) === 58) { - // check for t2-t6 - if (content.substr(pos+length+1, 3).match(/t[2-6]:/)) { - length += 3; - } - break; - } - - if (pos+length > content.length) { - return; - } - } - - if (length === MAX_NAME_LENGTH) { - return; - } - - return content.substr(pos, length); -} - -// straight forward :smile: to emoji image -function getEmojiTokenByName(name, state) { - - let info; - if (info = imageFor(name, state.md.options.discourse)) { - let token = new state.Token('emoji', 'img', 0); - token.attrs = [['src', info.url], - ['title', info.title], - ['class', info.classes], - ['alt', info.title]]; - - return token; - } -} - -function getEmojiTokenByTranslation(content, pos, state) { - - translationTree = translationTree || buildTranslationTree(); - - let currentTree = translationTree; - - let i; - let search = true; - let found = false; - let start = pos; - - while(search) { - - search = false; - let code = content.charCodeAt(pos); - - for (i=0;i 0) { - let leading = content.charAt(start-1); - if (!state.md.utils.isSpace(leading.charCodeAt(0)) && !state.md.utils.isPunctChar(leading)) { - return; - } - } - - // check trailing for punct or space - if (pos < content.length) { - let trailing = content.charCodeAt(pos); - if (!state.md.utils.isSpace(trailing)){ - return; - } - } - - let token = getEmojiTokenByName(found, state); - if (token) { - return { pos, token }; - } -} - -function applyEmoji(content, state, emojiUnicodeReplacer) { - let i; - let result = null; - let contentToken = null; - - let start = 0; - - if (emojiUnicodeReplacer) { - content = emojiUnicodeReplacer(content); - } - - let endToken = content.length; - - for (i=0; i0) { - contentToken = new state.Token('text', '', 0); - contentToken.content = content.slice(start,i); - result.push(contentToken); - } - - result.push(token); - endToken = start = i + offset; - } - } - - if (endToken < content.length) { - contentToken = new state.Token('text', '', 0); - contentToken.content = content.slice(endToken); - result.push(contentToken); - } - - return result; -} +registerOption((siteSettings, opts, state) => { + opts.features.emoji = !!siteSettings.enable_emoji; + opts.emojiSet = siteSettings.emoji_set || ""; + opts.customEmoji = state.customEmoji; +}); export function setup(helper) { - if (!helper.markdownIt) { return; } + if (helper.markdownIt) { return; } - helper.registerOptions((opts, siteSettings, state)=>{ - opts.features.emoji = !!siteSettings.enable_emoji; - opts.emojiSet = siteSettings.emoji_set || ""; - opts.customEmoji = state.customEmoji; + helper.whiteList('img.emoji'); + + function imageFor(code) { + code = code.toLowerCase(); + const opts = helper.getOptions(); + const url = buildEmojiUrl(code, opts); + if (url) { + const title = `:${code}:`; + const classes = isCustomEmoji(code, opts) ? "emoji emoji-custom" : "emoji"; + return ['img', { href: url, title, 'class': classes, alt: title }]; + } + } + + const translationsWithColon = {}; + Object.keys(translations).forEach(t => { + if (t[0] === ':') { + translationsWithColon[t] = translations[t]; + } else { + const replacement = translations[t]; + helper.inlineReplace(t, (token, match, prev) => { + return checkPrev(prev) ? imageFor(replacement) : token; + }); + } + }); + const translationColonRegexp = new RegExp(Object.keys(translationsWithColon).map(t => `(${escapeRegExp(t)})`).join("|")); + + helper.registerInline(':', (text, match, prev) => { + const endPos = text.indexOf(':', 1); + const firstSpace = text.search(/\s/); + if (!checkPrev(prev)) { return; } + + // If there is no trailing colon, check our translations that begin with colons + if (endPos === -1 || (firstSpace !== -1 && endPos > firstSpace)) { + translationColonRegexp.lastIndex = 0; + const m = translationColonRegexp.exec(text); + if (m && m[0] && text.indexOf(m[0]) === 0) { + // Check outer edge + const lastChar = text.charAt(m[0].length); + if (lastChar && !/\s/.test(lastChar)) return; + const contents = imageFor(translationsWithColon[m[0]]); + if (contents) { + return [m[0].length, contents]; + } + } + return; + } + + let between; + const emojiNameMatch = text.match(/(?:.*?)(:(?!:).?[\w-]*(?::t\d)?:)/); + if (emojiNameMatch) { + between = emojiNameMatch[0].slice(1, -1); + } else { + between = text.slice(1, -1); + } + + const contents = imageFor(between); + if (contents) { + return [text.indexOf(between, 1) + between.length + 1, contents]; + } }); - helper.registerPlugin((md)=>{ - md.core.ruler.push('emoji', state => md.options.discourse.helpers.textReplace( - state, (c,s)=>applyEmoji(c,s,md.options.discourse.emojiUnicodeReplacer)) - ); + helper.addPreProcessor(text => { + if (_unicodeReplacements) { + _unicodeRegexp.lastIndex = 0; + + let m; + while ((m = _unicodeRegexp.exec(text)) !== null) { + let replacement = ":" + _unicodeReplacements[m[0]] + ":"; + const before = text.charAt(m.index-1); + if (!/\B/.test(before)) { + replacement = "\u200b" + replacement; + } + text = text.replace(m[0], replacement); + } + } + return text; }); } diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/html.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/html.js.es6 new file mode 100644 index 00000000000..1d8f21a205b --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/html.js.es6 @@ -0,0 +1,52 @@ +const BLOCK_TAGS = ['address', 'article', 'aside', 'audio', 'blockquote', 'canvas', 'dd', 'details', + 'div', 'dl', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', + 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'iframe', 'noscript', 'ol', 'output', + 'p', 'pre', 'section', 'table', 'tfoot', 'ul', 'video', 'summary']; + +function splitAtLast(tag, block, next, first) { + const endTag = ``; + let endTagIndex = first ? block.indexOf(endTag) : block.lastIndexOf(endTag); + + if (endTagIndex !== -1) { + endTagIndex += endTag.length; + + const trailing = block.substr(endTagIndex).replace(/^\s+/, ''); + if (trailing.length) { + next.unshift(trailing); + } + + return [ block.substr(0, endTagIndex) ]; + } +}; + +export function setup(helper) { + + if (helper.markdownIt) { return; } + + // If a row begins with HTML tags, don't parse it. + helper.registerBlock('html', function(block, next) { + let split, pos; + + // Fix manual blockquote paragraphing even though it's not strictly correct + // PERF NOTE: /\S+
= 0) { + if(block.substring(0, pos).search(/\s/) === -1) { + split = splitAtLast('blockquote', block, next, true); + if (split) { return this.processInline(split[0]); } + } + } + + const m = /^\s*<\/?([^>]+)\>/.exec(block); + if (m && m[1]) { + const tag = m[1].split(/\s/); + if (tag && tag[0] && BLOCK_TAGS.indexOf(tag[0]) !== -1) { + split = splitAtLast(tag[0], block, next); + if (split) { + if (split.length === 1 && split[0] === block) { return; } + return split; + } + return [ block.toString() ]; + } + } + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js.es6 index 602af3c15ae..84be9e5f32b 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/mentions.js.es6 @@ -1,88 +1,51 @@ -const regex = /^(\w[\w.-]{0,59})\b/i; - -function applyMentions(state, silent, isWhiteSpace, isPunctChar, mentionLookup, getURL) { - - let pos = state.pos; - - // 64 = @ - if (silent || state.src.charCodeAt(pos) !== 64) { - return false; - } - - if (pos > 0) { - let prev = state.src.charCodeAt(pos-1); - if (!isWhiteSpace(prev) && !isPunctChar(String.fromCharCode(prev))) { - return false; - } - } - - // skip if in a link - if (state.tokens) { - let last = state.tokens[state.tokens.length-1]; - if (last) { - if (last.type === 'link_open') { - return false; - } - if (last.type === 'html_inline' && last.content.substr(0,2) === " { - md.inline.ruler.push('mentions', (state,silent)=> applyMentions( - state, - silent, - md.utils.isWhiteSpace, - md.utils.isPunctChar, - md.options.discourse.mentionLookup, - md.options.discourse.getURL - )); + // We have to prune @mentions that are within links. + helper.onParseNode(event => { + const node = event.node, + path = event.path; + + if (node[1] && node[1]["class"] === 'mention') { + const parent = path[path.length - 1]; + + // If the parent is an 'a', remove it + if (parent && parent[0] === 'a') { + const name = node[2]; + node.length = 0; + node[0] = "__RAW"; + node[1] = name; + } + } + }); + + helper.inlineRegexp({ + start: '@', + // NOTE: since we can't use SiteSettings here (they loads later in process) + // we are being less strict to account for more cases than allowed + matcher: /^@(\w[\w.-]{0,59})\b/i, + wordBoundary: true, + + emitter(matches) { + const mention = matches[0].trim(); + const name = matches[1]; + const opts = helper.getOptions(); + const mentionLookup = opts.mentionLookup; + + const type = mentionLookup && mentionLookup(name); + if (type === "user") { + return ['a', {'class': 'mention', href: opts.getURL("/u/") + name.toLowerCase()}, mention]; + } else if (type === "group") { + return ['a', {'class': 'mention-group', href: opts.getURL("/groups/") + name}, mention]; + } else { + return ['span', {'class': 'mention'}, mention]; + } + } }); } - diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/newline.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/newline.js.es6 index f1eb2ba759d..a453445a2c0 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/newline.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/newline.js.es6 @@ -1,53 +1,30 @@ -// see: https://github.com/markdown-it/markdown-it/issues/375 -// -// we use a custom paragraph rule cause we have to signal when a -// link starts with a space, so we can bypass a onebox -// this is a freedom patch, so careful, may break on updates - - -function newline(state, silent) { - var token, pmax, max, pos = state.pos; - - if (state.src.charCodeAt(pos) !== 0x0A/* \n */) { return false; } - - pmax = state.pending.length - 1; - max = state.posMax; - - // ' \n' -> hardbreak - // Lookup in pending chars is bad practice! Don't copy to other rules! - // Pending string is stored in concat mode, indexed lookups will cause - // convertion to flat mode. - if (!silent) { - if (pmax >= 0 && state.pending.charCodeAt(pmax) === 0x20) { - if (pmax >= 1 && state.pending.charCodeAt(pmax - 1) === 0x20) { - state.pending = state.pending.replace(/ +$/, ''); - token = state.push('hardbreak', 'br', 0); - } else { - state.pending = state.pending.slice(0, -1); - token = state.push('softbreak', 'br', 0); - } - - } else { - token = state.push('softbreak', 'br', 0); - } - } - - pos++; - - // skip heading spaces for next line - while (pos < max && state.md.utils.isSpace(state.src.charCodeAt(pos))) { - if (token) { - token.leading_space = true; - } - pos++; - } - - state.pos = pos; - return true; -}; +// Support for the newline behavior in markdown that most expect. Look through all text nodes +// in the tree, replace any new lines with `br`s. export function setup(helper) { - helper.registerPlugin(md => { - md.inline.ruler.at('newline', newline); + + if (helper.markdownIt) { return; } + + helper.postProcessText((text, event) => { + const { options, insideCounts } = event; + if (options.traditionalMarkdownLinebreaks || (insideCounts.pre > 0)) { return; } + + if (text === "\n") { + // If the tag is just a new line, replace it with a `
` + return [['br']]; + } else { + // If the text node contains new lines, perhaps with text between them, insert the + // `
` tags. + const split = text.split(/\n+/); + if (split.length) { + const replacement = []; + for (var i=0; i 0) { replacement.push(split[i]); } + if (i !== split.length-1) { replacement.push(['br']); } + } + + return replacement; + } + } }); } 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 5f04b72b1d0..875321911f8 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 @@ -1,89 +1,71 @@ import { lookupCache } from 'pretty-text/oneboxer'; -function applyOnebox(state, silent) { - if (silent || !state.tokens || state.tokens.length < 3) { - return; +// Given a node in the document and its parent, determine whether it is on its own line or not. +function isOnOneLine(link, parent) { + if (!parent) { return false; } + + const siblings = parent.slice(1); + if ((!siblings) || (siblings.length < 1)) { return false; } + + const idx = siblings.indexOf(link); + if (idx === -1) { return false; } + + if (idx > 0) { + const prev = siblings[idx-1]; + if (prev[0] !== 'br') { return false; } } - let i; - for(i=1;i 0) { - let prevSibling = token.children[j-1]; - - if (prevSibling.tag !== 'br' || prevSibling.leading_space) { - continue; - } - } - - // look ahead for soft or hard break - let text = token.children[j+1]; - let close = token.children[j+2]; - let lookahead = token.children[j+3]; - - if (lookahead && lookahead.tag !== 'br') { - continue; - } - - // check attrs only include a href - let attrs = child["attrs"]; - - if (!attrs || attrs.length !== 1 || attrs[0][0] !== "href") { - continue; - } - - // we already know text matches cause it is an auto link - - if (!close || close.type !== "link_close") { - continue; - } - - // we already determined earlier that 0 0 was href - let cached = lookupCache(attrs[0][1]); - - if (cached) { - // replace link with 2 blank text nodes and inline html for onebox - child.type = 'html_raw'; - child.content = cached; - child.inline = true; - - text.type = 'html_raw'; - text.content = ''; - text.inline = true; - - close.type = 'html_raw'; - close.content = ''; - close.inline = true; - - } else { - // decorate... - attrs.push(["class", "onebox"]); - } - } - } - } + if (idx < siblings.length) { + const next = siblings[idx+1]; + if (next && (!((next[0] === 'br') || (typeof next === 'string' && next.trim() === "")))) { return false; } } + + return true; } - +// We only onebox stuff that is on its own line. export function setup(helper) { - if (!helper.markdownIt) { return; } + if (helper.markdownIt) { return; } - helper.registerPlugin(md => { - md.core.ruler.after('linkify', 'onebox', applyOnebox); + helper.onParseNode(event => { + const node = event.node, + path = event.path; + + // We only care about links + if (node[0] !== 'a') { return; } + + const parent = path[path.length - 1]; + + // We don't onebox bbcode + if (node[1]['data-bbcode']) { + delete node[1]['data-bbcode']; + return; + } + + // We don't onebox mentions + if (node[1]['class'] === 'mention') { return; } + + // Don't onebox links within a list + for (var i=0; i { + opts.enableEmoji = siteSettings.enable_emoji; + opts.emojiSet = siteSettings.emoji_set; +}); + + +export function setup(helper) { + + if (helper.markdownIt) { return; } + + register(helper, 'quote', {noWrap: true, singlePara: true}, (contents, bbParams, options) => { + + const params = {'class': 'quote'}; + let username = null; + const opts = helper.getOptions(); + + if (bbParams) { + const paramsSplit = bbParams.split(/\,\s*/); + username = paramsSplit[0]; + + paramsSplit.forEach(function(p,i) { + if (i > 0) { + var assignment = p.split(':'); + if (assignment[0] && assignment[1]) { + const escaped = helper.escape(assignment[0]); + // don't escape attributes, makes no sense + if (escaped === assignment[0]) { + params['data-' + assignment[0]] = helper.escape(assignment[1].trim()); + } + } + } + }); + } + + let avatarImg; + const postNumber = parseInt(params['data-post'], 10); + const topicId = parseInt(params['data-topic'], 10); + + if (options.lookupAvatarByPostNumber) { + // client-side, we can retrieve the avatar from the post + avatarImg = options.lookupAvatarByPostNumber(postNumber, topicId); + } else if (options.lookupAvatar) { + // server-side, we need to lookup the avatar from the username + avatarImg = options.lookupAvatar(username); + } + + // If there's no username just return a simple quote + if (!username) { + return ['p', ['aside', params, ['blockquote'].concat(contents)]]; + } + + const header = ['div', {'class': 'title'}, + ['div', {'class': 'quote-controls'}], + avatarImg ? ['__RAW', avatarImg] : "", + username ? `${username}:` : "" ]; + + if (options.topicId && postNumber && options.getTopicInfo && topicId !== options.topicId) { + const topicInfo = options.getTopicInfo(topicId); + if (topicInfo) { + var href = topicInfo.href; + if (postNumber > 0) { href += "/" + postNumber; } + // get rid of username said stuff + header.pop(); + + let title = topicInfo.title; + + if (opts.enableEmoji) { + title = performEmojiUnescape(topicInfo.title, { + getURL: opts.getURL, emojiSet: opts.emojiSet + }); + } + + header.push(['a', {'href': href}, title]); + } + } + + return ['aside', params, header, ['blockquote'].concat(contents)]; + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js.es6 b/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js.es6 index 4bb5ef92d62..1b148e68434 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/discourse-markdown/table.js.es6 @@ -1,31 +1,35 @@ -export function setup(helper) { +import { registerOption } from 'pretty-text/pretty-text'; - if (!helper.markdownIt) { return; } +function tableFlattenBlocks(blocks) { + let result = ""; - // this is built in now - // TODO: sanitizer needs fixing, does not properly support this yet - - // we need a custom callback for style handling - helper.whiteList({ - custom: function(tag,attr,val) { - if (tag !== 'th' && tag !== 'td') { - return false; - } - - if (attr !== 'style') { - return false; - } - - return (val === 'text-align:right' || val === 'text-align:left' || val === 'text-align:center'); - } + blocks.forEach(b => { + result += b; + if (b.trailing) { result += b.trailing; } }); - helper.whiteList([ - 'table', - 'tbody', - 'thead', - 'tr', - 'th', - 'td', - ]); + // bypass newline insertion + return result.replace(/[\n\r]/g, " "); +}; + +registerOption((siteSettings, opts) => { + opts.features.table = !!siteSettings.allow_html_tables; +}); + +export function setup(helper) { + + if (helper.markdownIt) { return; } + + helper.whiteList(['table', 'table.md-table', 'tbody', 'thead', 'tr', 'th', 'td']); + + helper.replaceBlock({ + start: /(]*>)([\S\s]*)/igm, + stop: /<\/table>/igm, + rawContents: true, + priority: 1, + + emitter(contents) { + return ['table', {"class": "md-table"}, tableFlattenBlocks.apply(this, [contents])]; + } + }); } diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/bbcode-block.js.es6 similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-block.js.es6 rename to app/assets/javascripts/pretty-text/engines/markdown-it/bbcode-block.js.es6 diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/bbcode-inline.js.es6 similarity index 98% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6 rename to app/assets/javascripts/pretty-text/engines/markdown-it/bbcode-inline.js.es6 index 71ce50462af..2184adb7ffd 100644 --- a/app/assets/javascripts/pretty-text/engines/discourse-markdown/bbcode-inline.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/bbcode-inline.js.es6 @@ -1,4 +1,4 @@ -import { parseBBCodeTag } from 'pretty-text/engines/discourse-markdown/bbcode-block'; +import { parseBBCodeTag } from 'pretty-text/engines/markdown-it/bbcode-block'; function tokanizeBBCode(state, silent, ruler) { diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/category-hashtag.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/category-hashtag.js.es6 new file mode 100644 index 00000000000..79d57002a68 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/category-hashtag.js.es6 @@ -0,0 +1,104 @@ +function addHashtag(buffer, matches, state) { + const options = state.md.options.discourse; + const [hashtag, slug] = matches; + const categoryHashtagLookup = options.categoryHashtagLookup; + const result = categoryHashtagLookup && categoryHashtagLookup(slug); + + let token; + + if (result) { + token = new state.Token('link_open', 'a', 1); + token.attrs = [['class', 'hashtag'], ['href', result[0]]]; + token.block = false; + buffer.push(token); + + token = new state.Token('text', '', 0); + token.content = '#'; + buffer.push(token); + + token = new state.Token('span_open', 'span', 1); + token.block = false; + buffer.push(token); + + token = new state.Token('text', '', 0); + token.content = result[1]; + buffer.push(token); + + buffer.push(new state.Token('span_close', 'span', -1)); + + buffer.push(new state.Token('link_close', 'a', -1)); + } else { + + token = new state.Token('span_open', 'span', 1); + token.attrs = [['class', 'hashtag']]; + buffer.push(token); + + token = new state.Token('text', '', 0); + token.content = hashtag; + buffer.push(token); + + token = new state.Token('span_close', 'span', -1); + buffer.push(token); + } +} + +const REGEX = /#([\w-:]{1,101})/gi; + +function allowedBoundary(content, index, utils) { + let code = content.charCodeAt(index); + return (utils.isWhiteSpace(code) || utils.isPunctChar(String.fromCharCode(code))); +} + +function applyHashtag(content, state) { + let result = null, + match, + pos = 0; + + while (match = REGEX.exec(content)) { + // check boundary + if (match.index > 0) { + if (!allowedBoundary(content, match.index-1, state.md.utils)) { + continue; + } + } + + // check forward boundary as well + if (match.index + match[0].length < content.length) { + if (!allowedBoundary(content, match.index + match[0].length, state.md.utils)) { + continue; + } + } + + if (match.index > pos) { + result = result || []; + let token = new state.Token('text', '', 0); + token.content = content.slice(pos, match.index); + result.push(token); + } + + result = result || []; + addHashtag(result, match, state); + + pos = match.index + match[0].length; + } + + if (result && pos < content.length) { + let token = new state.Token('text', '', 0); + token.content = content.slice(pos); + result.push(token); + } + + return result; +} + +export function setup(helper) { + + if (!helper.markdownIt) { return; } + + helper.registerPlugin(md=>{ + + md.core.ruler.push('category-hashtag', state => md.options.discourse.helpers.textReplace( + state, applyHashtag, true /* skip all links */ + )); + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/censored.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/censored.js.es6 new file mode 100644 index 00000000000..3d5cb8d9313 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/censored.js.es6 @@ -0,0 +1,44 @@ +import { censorFn } from 'pretty-text/censored-words'; + +function recurse(tokens, apply) { + let i; + for(i=0;i { + if (token.content) { + token.content = censor(token.content); + } + }); +} + +export function setup(helper) { + + if (!helper.markdownIt) { return; } + + helper.registerOptions((opts, siteSettings) => { + opts.censoredWords = siteSettings.censored_words; + opts.censoredPattern = siteSettings.censored_pattern; + }); + + helper.registerPlugin(md => { + const words = md.options.discourse.censoredWords; + const patterns = md.options.discourse.censoredPattern; + + if ((words && words.length > 0) || (patterns && patterns.length > 0)) { + const replacement = String.fromCharCode(9632); + const censor = censorFn(words, patterns, replacement); + md.core.ruler.push('censored', state => censorTree(state, censor)); + } + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/code.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/code.js.es6 new file mode 100644 index 00000000000..c8d94967a1e --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/code.js.es6 @@ -0,0 +1,51 @@ +// we need a custom renderer for code blocks cause we have a slightly non compliant +// format with special handling for text and so on + +const TEXT_CODE_CLASSES = ["text", "pre", "plain"]; + + +function render(tokens, idx, options, env, slf, md) { + let token = tokens[idx], + info = token.info ? md.utils.unescapeAll(token.info) : '', + langName = md.options.discourse.defaultCodeLang, + className, + escapedContent = md.utils.escapeHtml(token.content); + + if (info) { + // strip off any additional languages + info = info.split(/\s+/g)[0]; + } + + const acceptableCodeClasses = md.options.discourse.acceptableCodeClasses; + if (acceptableCodeClasses && info && acceptableCodeClasses.indexOf(info) !== -1) { + langName = info; + } + + className = TEXT_CODE_CLASSES.indexOf(info) !== -1 ? 'lang-nohighlight' : 'lang-' + langName; + + return `
${escapedContent}
\n`; +} + +export function setup(helper) { + if (!helper.markdownIt) { return; } + + helper.registerOptions((opts, siteSettings) => { + opts.defaultCodeLang = siteSettings.default_code_lang; + opts.acceptableCodeClasses = (siteSettings.highlighted_languages || "").split("|").concat(['auto', 'nohighlight']); + }); + + helper.whiteList({ + custom(tag, name, value) { + if (tag === 'code' && name === 'class') { + const m = /^lang\-(.+)$/.exec(value); + if (m) { + return helper.getOptions().acceptableCodeClasses.indexOf(m[1]) !== -1; + } + } + } + }); + + helper.registerPlugin(md=>{ + md.renderer.rules.fence = (tokens,idx,options,env,slf)=>render(tokens,idx,options,env,slf,md); + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/emoji.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/emoji.js.es6 new file mode 100644 index 00000000000..0e4eed203bf --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/emoji.js.es6 @@ -0,0 +1,246 @@ +import { buildEmojiUrl, isCustomEmoji } from 'pretty-text/emoji'; +import { translations } from 'pretty-text/emoji/data'; + +const MAX_NAME_LENGTH = 60; + +let translationTree = null; + +// This allows us to efficiently search for aliases +// We build a data structure that allows us to quickly +// search through our N next chars to see if any match +// one of our alias emojis. +// +function buildTranslationTree() { + let tree = []; + let lastNode; + + Object.keys(translations).forEach(function(key){ + let i; + let node = tree; + + for(i=0;i 0) { + let prev = content.charCodeAt(pos-1); + if (!state.md.utils.isSpace(prev) && !state.md.utils.isPunctChar(String.fromCharCode(prev))) { + return; + } + } + + pos++; + if (content.charCodeAt(pos) === 58) { + return; + } + + let length = 0; + while(length < MAX_NAME_LENGTH) { + length++; + + if (content.charCodeAt(pos+length) === 58) { + // check for t2-t6 + if (content.substr(pos+length+1, 3).match(/t[2-6]:/)) { + length += 3; + } + break; + } + + if (pos+length > content.length) { + return; + } + } + + if (length === MAX_NAME_LENGTH) { + return; + } + + return content.substr(pos, length); +} + +// straight forward :smile: to emoji image +function getEmojiTokenByName(name, state) { + + let info; + if (info = imageFor(name, state.md.options.discourse)) { + let token = new state.Token('emoji', 'img', 0); + token.attrs = [['src', info.url], + ['title', info.title], + ['class', info.classes], + ['alt', info.title]]; + + return token; + } +} + +function getEmojiTokenByTranslation(content, pos, state) { + + translationTree = translationTree || buildTranslationTree(); + + let currentTree = translationTree; + + let i; + let search = true; + let found = false; + let start = pos; + + while(search) { + + search = false; + let code = content.charCodeAt(pos); + + for (i=0;i 0) { + let leading = content.charAt(start-1); + if (!state.md.utils.isSpace(leading.charCodeAt(0)) && !state.md.utils.isPunctChar(leading)) { + return; + } + } + + // check trailing for punct or space + if (pos < content.length) { + let trailing = content.charCodeAt(pos); + if (!state.md.utils.isSpace(trailing)){ + return; + } + } + + let token = getEmojiTokenByName(found, state); + if (token) { + return { pos, token }; + } +} + +function applyEmoji(content, state, emojiUnicodeReplacer) { + let i; + let result = null; + let contentToken = null; + + let start = 0; + + if (emojiUnicodeReplacer) { + content = emojiUnicodeReplacer(content); + } + + let endToken = content.length; + + for (i=0; i0) { + contentToken = new state.Token('text', '', 0); + contentToken.content = content.slice(start,i); + result.push(contentToken); + } + + result.push(token); + endToken = start = i + offset; + } + } + + if (endToken < content.length) { + contentToken = new state.Token('text', '', 0); + contentToken.content = content.slice(endToken); + result.push(contentToken); + } + + return result; +} + +export function setup(helper) { + + if (!helper.markdownIt) { return; } + + helper.registerOptions((opts, siteSettings, state)=>{ + opts.features.emoji = !!siteSettings.enable_emoji; + opts.emojiSet = siteSettings.emoji_set || ""; + opts.customEmoji = state.customEmoji; + }); + + helper.registerPlugin((md)=>{ + md.core.ruler.push('emoji', state => md.options.discourse.helpers.textReplace( + state, (c,s)=>applyEmoji(c,s,md.options.discourse.emojiUnicodeReplacer)) + ); + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/helpers.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/helpers.js.es6 similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/helpers.js.es6 rename to app/assets/javascripts/pretty-text/engines/markdown-it/helpers.js.es6 diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/html_img.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/html_img.js.es6 similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/html_img.js.es6 rename to app/assets/javascripts/pretty-text/engines/markdown-it/html_img.js.es6 diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/mentions.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/mentions.js.es6 new file mode 100644 index 00000000000..602af3c15ae --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/mentions.js.es6 @@ -0,0 +1,88 @@ +const regex = /^(\w[\w.-]{0,59})\b/i; + +function applyMentions(state, silent, isWhiteSpace, isPunctChar, mentionLookup, getURL) { + + let pos = state.pos; + + // 64 = @ + if (silent || state.src.charCodeAt(pos) !== 64) { + return false; + } + + if (pos > 0) { + let prev = state.src.charCodeAt(pos-1); + if (!isWhiteSpace(prev) && !isPunctChar(String.fromCharCode(prev))) { + return false; + } + } + + // skip if in a link + if (state.tokens) { + let last = state.tokens[state.tokens.length-1]; + if (last) { + if (last.type === 'link_open') { + return false; + } + if (last.type === 'html_inline' && last.content.substr(0,2) === " { + md.inline.ruler.push('mentions', (state,silent)=> applyMentions( + state, + silent, + md.utils.isWhiteSpace, + md.utils.isPunctChar, + md.options.discourse.mentionLookup, + md.options.discourse.getURL + )); + }); +} + diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/newline.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/newline.js.es6 new file mode 100644 index 00000000000..f1eb2ba759d --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/newline.js.es6 @@ -0,0 +1,53 @@ +// see: https://github.com/markdown-it/markdown-it/issues/375 +// +// we use a custom paragraph rule cause we have to signal when a +// link starts with a space, so we can bypass a onebox +// this is a freedom patch, so careful, may break on updates + + +function newline(state, silent) { + var token, pmax, max, pos = state.pos; + + if (state.src.charCodeAt(pos) !== 0x0A/* \n */) { return false; } + + pmax = state.pending.length - 1; + max = state.posMax; + + // ' \n' -> hardbreak + // Lookup in pending chars is bad practice! Don't copy to other rules! + // Pending string is stored in concat mode, indexed lookups will cause + // convertion to flat mode. + if (!silent) { + if (pmax >= 0 && state.pending.charCodeAt(pmax) === 0x20) { + if (pmax >= 1 && state.pending.charCodeAt(pmax - 1) === 0x20) { + state.pending = state.pending.replace(/ +$/, ''); + token = state.push('hardbreak', 'br', 0); + } else { + state.pending = state.pending.slice(0, -1); + token = state.push('softbreak', 'br', 0); + } + + } else { + token = state.push('softbreak', 'br', 0); + } + } + + pos++; + + // skip heading spaces for next line + while (pos < max && state.md.utils.isSpace(state.src.charCodeAt(pos))) { + if (token) { + token.leading_space = true; + } + pos++; + } + + state.pos = pos; + return true; +}; + +export function setup(helper) { + helper.registerPlugin(md => { + md.inline.ruler.at('newline', newline); + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/onebox.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/onebox.js.es6 new file mode 100644 index 00000000000..5f04b72b1d0 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/onebox.js.es6 @@ -0,0 +1,89 @@ +import { lookupCache } from 'pretty-text/oneboxer'; + +function applyOnebox(state, silent) { + if (silent || !state.tokens || state.tokens.length < 3) { + return; + } + + let i; + for(i=1;i 0) { + let prevSibling = token.children[j-1]; + + if (prevSibling.tag !== 'br' || prevSibling.leading_space) { + continue; + } + } + + // look ahead for soft or hard break + let text = token.children[j+1]; + let close = token.children[j+2]; + let lookahead = token.children[j+3]; + + if (lookahead && lookahead.tag !== 'br') { + continue; + } + + // check attrs only include a href + let attrs = child["attrs"]; + + if (!attrs || attrs.length !== 1 || attrs[0][0] !== "href") { + continue; + } + + // we already know text matches cause it is an auto link + + if (!close || close.type !== "link_close") { + continue; + } + + // we already determined earlier that 0 0 was href + let cached = lookupCache(attrs[0][1]); + + if (cached) { + // replace link with 2 blank text nodes and inline html for onebox + child.type = 'html_raw'; + child.content = cached; + child.inline = true; + + text.type = 'html_raw'; + text.content = ''; + text.inline = true; + + close.type = 'html_raw'; + close.content = ''; + close.inline = true; + + } else { + // decorate... + attrs.push(["class", "onebox"]); + } + } + } + } + } +} + + +export function setup(helper) { + + if (!helper.markdownIt) { return; } + + helper.registerPlugin(md => { + md.core.ruler.after('linkify', 'onebox', applyOnebox); + }); +} diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/paragraph.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/paragraph.js.es6 similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/paragraph.js.es6 rename to app/assets/javascripts/pretty-text/engines/markdown-it/paragraph.js.es6 diff --git a/app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/quotes.js.es6 similarity index 100% rename from app/assets/javascripts/pretty-text/engines/discourse-markdown/quotes.js.es6 rename to app/assets/javascripts/pretty-text/engines/markdown-it/quotes.js.es6 diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/table.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/table.js.es6 new file mode 100644 index 00000000000..4bb5ef92d62 --- /dev/null +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/table.js.es6 @@ -0,0 +1,31 @@ +export function setup(helper) { + + if (!helper.markdownIt) { return; } + + // this is built in now + // TODO: sanitizer needs fixing, does not properly support this yet + + // we need a custom callback for style handling + helper.whiteList({ + custom: function(tag,attr,val) { + if (tag !== 'th' && tag !== 'td') { + return false; + } + + if (attr !== 'style') { + return false; + } + + return (val === 'text-align:right' || val === 'text-align:left' || val === 'text-align:center'); + } + }); + + helper.whiteList([ + 'table', + 'tbody', + 'thead', + 'tr', + 'th', + 'td', + ]); +} diff --git a/app/assets/javascripts/pretty-text/pretty-text.js.es6 b/app/assets/javascripts/pretty-text/pretty-text.js.es6 index bfd6eaf904f..f2c4633d20c 100644 --- a/app/assets/javascripts/pretty-text/pretty-text.js.es6 +++ b/app/assets/javascripts/pretty-text/pretty-text.js.es6 @@ -1,3 +1,4 @@ +import { cook, setup } from 'pretty-text/engines/discourse-markdown'; 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'; @@ -24,6 +25,10 @@ export function buildOptions(state) { emojiUnicodeReplacer } = state; + if (!siteSettings.enable_experimental_markdown_it) { + setup(); + } + const features = { 'bold-italics': true, 'auto-link': true, @@ -51,10 +56,15 @@ export function buildOptions(state) { mentionLookup: state.mentionLookup, emojiUnicodeReplacer, allowedHrefSchemes: siteSettings.allowed_href_schemes ? siteSettings.allowed_href_schemes.split('|') : null, - markdownIt: true + markdownIt: siteSettings.enable_experimental_markdown_it }; - setupIt(options, siteSettings, state); + if (siteSettings.enable_experimental_markdown_it) { + setupIt(options, siteSettings, state); + } else { + // TODO deprecate this + _registerFns.forEach(fn => fn(siteSettings, options, state)); + } return options; } @@ -64,13 +74,22 @@ export default class { this.opts = opts || {}; this.opts.features = this.opts.features || {}; this.opts.sanitizer = (!!this.opts.sanitize) ? (this.opts.sanitizer || sanitize) : identity; + // We used to do a failsafe call to setup here + // under new engine we always expect setup to be called by buildOptions. + // setup(); } cook(raw) { if (!raw || raw.length === 0) { return ""; } let result; - result = cookIt(raw, this.opts); + + if (this.opts.markdownIt) { + result = cookIt(raw, this.opts); + } else { + result = cook(raw, this.opts); + } + return result ? result : ""; } diff --git a/app/views/common/_discourse_javascript.html.erb b/app/views/common/_discourse_javascript.html.erb index f9b01467938..1d30e8e2965 100644 --- a/app/views/common/_discourse_javascript.html.erb +++ b/app/views/common/_discourse_javascript.html.erb @@ -42,7 +42,9 @@ Discourse.Environment = '<%= Rails.env %>'; Discourse.SiteSettings = ps.get('siteSettings'); Discourse.LetterAvatarVersion = '<%= LetterAvatar.version %>'; + <%- if SiteSetting.enable_experimental_markdown_it %> Discourse.MarkdownItURL = '<%= asset_url('markdown-it-bundle.js') %>'; + <%- end %> I18n.defaultLocale = '<%= SiteSetting.default_locale %>'; Discourse.start(); Discourse.set('assetVersion','<%= Discourse.assets_digest %>'); diff --git a/config/locales/server.ar.yml b/config/locales/server.ar.yml index ab722fe3f9d..bff1fc2adfa 100644 --- a/config/locales/server.ar.yml +++ b/config/locales/server.ar.yml @@ -946,6 +946,7 @@ ar: notify_mods_when_user_blocked: "إذا تم حظر المستخدم تلقائيا، وإرسال رسالة الى جميع المشرفين." flag_sockpuppets: "إذا رد أحد المستخدمين جديد إلى موضوع من عنوان IP نفسه باسم المستخدم الجديد الذي بدأ هذا الموضوع، علم كل من مناصبهم كدعاية المحتملين." traditional_markdown_linebreaks: "استعمل السطور التالفه التقليديه في Markdown, التي تتطلب مساحتين بيضاوين للسطور التالفه" + allow_html_tables: "كل الجداول يجب ان تدخل ب لغة ال HTML مثال TABLE , THEAD , TD , TR , TH سوف يأوذن لهم ( تتطلب مراجعة لكل المقالات القديمة )" post_undo_action_window_mins: "عدد الدقائق التي يسمح فيها للأعضاء بالتراجع عن آخر إجراءاتهم على المنشور (إعجاب، اشارة، إلخ...)" must_approve_users: "يجب أن الموظفين يوافق على جميع حسابات المستخدم الجديدة قبل أن يتم السماح لهم للوصول إلى الموقع. تحذير: تمكين هذا لموقع الحية إلغاء وصول المستخدمين الحاليين غير الموظفين!" pending_users_reminder_delay: "نبه المشرفين إذا وجد اعضاء ينتظرون الموافقة لمدة اطول من الساعات ، قم بوضع الخيار -1 لايقاف التنبيهات ." diff --git a/config/locales/server.da.yml b/config/locales/server.da.yml index ea748fa7e04..b8244744d02 100644 --- a/config/locales/server.da.yml +++ b/config/locales/server.da.yml @@ -903,6 +903,7 @@ da: notify_mods_when_user_blocked: "Send en besked til alle moderatorer hvis en bruger blokeres automatisk" flag_sockpuppets: "Hvis en ny bruger svarer på et emne fra den samme IP adresse som den der startede emnet, så rapporter begge at deres indlæg potentielt er spam." traditional_markdown_linebreaks: "Brug traditionelle linjeskift i Markdown, som kræver 2 mellemrum i slutningen af sætningen." + allow_html_tables: "Tillad tabeller at blive oprettet i Markdown med brug af HTML tags. TABLE, THEAD, TD, TR, TH vil blive tilladt (kræver en fuld re-indeksering af gamle indlæg som benytter tabeller) " post_undo_action_window_mins: "Antal minutter som brugere er tilladt at fortryde handlinger på et indlæg (like, flag, etc)." must_approve_users: "Personale skal godkende alle nye bruger konti inden de kan tilgå sitet. ADVARSEL: aktivering af dette for et live site vil medføre en ophævning af adgang for eksisterende ikke-personale brugere." pending_users_reminder_delay: "Underret moderatorer hvis nye brugere har ventet på godkendelse i længere end så mange timer. Skriv -1 for at deaktivere notifikationer." diff --git a/config/locales/server.de.yml b/config/locales/server.de.yml index 5792ad524cd..024f52ccf2c 100644 --- a/config/locales/server.de.yml +++ b/config/locales/server.de.yml @@ -921,6 +921,7 @@ de: notify_mods_when_user_blocked: "Wenn ein Benutzer automatisch gesperrt wird, sende eine Nachricht an alle Moderatoren." flag_sockpuppets: "Wenn ein neuer Benutzer auf ein Thema antwortet, das von einem anderen neuen Benutzer aber mit der gleichen IP-Adresse begonnen wurde, markiere beide Beiträge als potenziellen Spam." traditional_markdown_linebreaks: "Traditionelle Zeilenumbrüche in Markdown, die zwei nachfolgende Leerzeichen für einen Zeilenumbruch benötigen." + allow_html_tables: "Erlaube es, Tabellen in Markdown mit HTML-Tags einzugeben. TABLE, THEAD, TD, TR, TH werden erlaubt (alle Beiträge mit Tabellen müssen ihr HTML erneuern)" post_undo_action_window_mins: "Minuten, die ein Benutzer hat, um Aktionen auf einen Beitrag rückgängig zu machen (Gefällt mir, Meldung, usw.)." must_approve_users: "Team-Mitglieder müssen alle neuen Benutzerkonten freischalten, bevor diese Zugriff auf die Website erhalten. ACHTUNG: Das Aktivieren dieser Option für eine Live-Site entfernt den Zugriff auch für alle existierenden Benutzer außer für Team-Mitglieder!" pending_users_reminder_delay: "Benachrichtige die Moderatoren, falls neue Benutzer mehr als so viele Stunden auf ihre Genehmigung gewartet haben. Stelle -1 ein, um diese Benachrichtigungen zu deaktivieren." diff --git a/config/locales/server.el.yml b/config/locales/server.el.yml index 3ad65a40043..ed8e3e75542 100644 --- a/config/locales/server.el.yml +++ b/config/locales/server.el.yml @@ -890,6 +890,7 @@ el: notify_mods_when_user_blocked: "Εάν ένας χρήστης αυτόματα μπλοκαριστει, στείλε μήνυμα σε όλους τους συντονιστές." flag_sockpuppets: "Εάν ένας νέος χρήστης απαντήσει σε ένα νήμα από την ίδια διεύθυνση ΙP όπως ο νέος χρήστης, ο οποίος ξεκίνησε το νήμα, και οι δυο δημοσιεύσεις τους θα επισημανθούν ως δυνητικά ανεπιθύμητες." traditional_markdown_linebreaks: "Χρήση παραδοσιακών αλλαγών γραμμών στη Markdown, η οποία απαιτεί δύο κενά διαστήματα για μια αλλαγή γραμμής." + allow_html_tables: "Αποδοχή εισδοχής πινάκων στη Markdown με τη χρήση ετικετών HTML. TABLE, THEAD, TD, TR, TH θα μπαίνουν στη λίστα επιτρεπόμενων (απαιτείται πληρής αντιγραφή σε όλες τις αναρτήσεις που περιέχουν πίνακες)" post_undo_action_window_mins: "Αριθμός των λεπτών όπου οι χρήστες δικαιούνται να αναιρέσουν πρόσφατες ενέργειες πάνω σε ένα θέμα (μου αρέσει, επισήμανση, κτλ) " must_approve_users: "Το προσωπικό πρέπει να εγκρίνει όλους τους λογαριασμούς των νέων χρηστών προτού τους επιτραπεί να έχουν πρόσβαση στην ιστοσελίδα. Προειδοποίηση: ενεργοποιώντας το για μια ζωντανή ιστοσελίδα θα έχει ως αποτέλεσμα την ανάκληση για τους υπάρχοντες χρήστες που δεν ανήκουν στο προσωπικό!" pending_users_reminder_delay: "Ειδοποίηση συντονιστών αν καινούργιοι χρήστες περιμένουν για αποδοχή για μεγαλύτερο απο αυτό το χρονικό διάστημα. Όρισέ το στο -1 για να απενεργοποιηθούν οι ειδοποιήσεις." diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 57baa880b49..67a2dce7907 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1024,7 +1024,9 @@ en: flag_sockpuppets: "If a new user replies to a topic from the same IP address as the new user who started the topic, flag both of their posts as potential spam." traditional_markdown_linebreaks: "Use traditional linebreaks in Markdown, which require two trailing spaces for a linebreak." + enable_experimental_markdown_it: "Enable the experimental markdown.it CommonMark engine, WARNING: some plugins may not work correctly" enable_markdown_typographer: "Use basic typography rules to improve text readability of paragraphs of text, replaces (c) (tm) etc, with symbols, reduces number of question marks and so on" + allow_html_tables: "Allow tables to be entered in Markdown using HTML tags. TABLE, THEAD, TD, TR, TH will be whitelisted (requires full rebake on all old posts containing tables)" post_undo_action_window_mins: "Number of minutes users are allowed to undo recent actions on a post (like, flag, etc)." must_approve_users: "Staff must approve all new user accounts before they are allowed to access the site. WARNING: enabling this for a live site will revoke access for existing non-staff users!" pending_users_reminder_delay: "Notify moderators if new users have been waiting for approval for longer than this many hours. Set to -1 to disable notifications." diff --git a/config/locales/server.es.yml b/config/locales/server.es.yml index 775899a0f78..1dbe6baf1f9 100644 --- a/config/locales/server.es.yml +++ b/config/locales/server.es.yml @@ -932,7 +932,9 @@ es: notify_mods_when_user_blocked: "Si un usuario es bloqueado automáticamente, enviar un mensaje a todos los moderadores." flag_sockpuppets: "Si un nuevo usuario responde a un tema desde la misma dirección de IP que el nuevo usuario que inició el tema, reportar los posts de los dos como spam en potencia." traditional_markdown_linebreaks: "Utiliza saltos de línea tradicionales en Markdown, que requieren dos espacios al final para un salto de línea." + enable_experimental_markdown_it: "Habilitar el motor experimental CommonMark markdown.it, ADVERTENCIA: algunos plugins podrían no funcionar correctamente." enable_markdown_typographer: "Utilice reglas básicas de tipografía para mejorar la legibilidad de texto de los párrafos de texto, reemplaza (c) (tm) etc, con símbolos, reduce el número de signos de interrogación y así sucesivamente" + allow_html_tables: "Permitir la inserción de tablas en Markdown usando etiquetas HTML. Se permitirá usar TABLE, THEAD, TD, TR o TH (requiere un rebake completo para los posts antiguos que contengan tablas)" post_undo_action_window_mins: "Número de minutos durante los cuales los usuarios pueden deshacer sus acciones recientes en un post (me gusta, reportes, etc)." must_approve_users: "Los miembros administración deben aprobar todas las nuevas cuentas antes de que se les permita el acceso al sitio. AVISO: ¡habilitar esta opción en un sitio activo revocará el acceso a los usuarios que no sean moderadores o admin!" pending_users_reminder_delay: "Notificar a los moderadores si hay nuevos usuarios que hayan estado esperando aprbación durante más estas horas. Usa -1 para desactivar estas notificaciones." diff --git a/config/locales/server.fa_IR.yml b/config/locales/server.fa_IR.yml index a1dc35576d4..209ab34571a 100644 --- a/config/locales/server.fa_IR.yml +++ b/config/locales/server.fa_IR.yml @@ -884,6 +884,7 @@ fa_IR: notify_mods_when_user_blocked: "اگر کاربر به‌طور خودکار مسدود شد، به تمام مدیران پیام بفرست." flag_sockpuppets: "اگر کاربری به موضوع با ای پی برابر با کاربری که نوشته را شروع کرده ٬ آنها را به عنوان هرزنامه پرچم گزاری کن." traditional_markdown_linebreaks: "در مدل‌های نشانه گزاری از خط جدید سنتی استفاده کن،‌ که برای linebreak نیاز به دو فضای انتهایی دارد ." + allow_html_tables: "اجازه ارسال جدول به صورت markdown با تگ های HTML. TABLE, THEAD, TD, TR, TH قابل استفاده هستند. (نیازمند ایجا دوباره در نوشته‌های قدیمی که شامل جدول هستند)" post_undo_action_window_mins: "تعداد دقایقی که کاربران اجازه دارند اقدامی را که در نوشته انجام داده اند باز گردانند. (پسند، پرچم گذاری،‌ چیزهای دیگر)." must_approve_users: "همکاران باید تمامی حساب‌های کاربری را قبل از اجازه دسترسی به سایت تایید کنند. اخطار: فعال‌سازی این گزینه ممکن است باعث جلوگیری از دسترسی کاربرانی که قبلا عضو شده‌اند نیز بشود!" pending_users_reminder_delay: "اگر کاربر‌ها بیشتر از این مقدار ساعت منتظر تایید بودند به مدیران اعلام کن. مقدار -1 برای غیرفعال‌سازی." diff --git a/config/locales/server.fi.yml b/config/locales/server.fi.yml index d2aad62daa6..9c699ac9ec1 100644 --- a/config/locales/server.fi.yml +++ b/config/locales/server.fi.yml @@ -931,7 +931,9 @@ fi: notify_mods_when_user_blocked: "Jos käyttäjä estetään automaattisesti, lähetä viesti kaikille valvojille." flag_sockpuppets: "Jos uuden käyttäjän luomaan ketjuun vastaa toinen uusi käyttäjä samasta IP-osoitteesta, liputa molemmat viestit mahdolliseksi roskapostiksi." traditional_markdown_linebreaks: "Käytä perinteisiä rivinvaihtoja Markdownissa, joka vaatii kaksi perättäistä välilyöntiä rivin vaihtoon." + enable_experimental_markdown_it: "Ota käyttöön kokeellinen markdown.it Commonmark ohjelmistomoottori. VAROITUS: jotkut lisäosat voivat lakata toimimasta oikein" enable_markdown_typographer: "Käytetään tavanomaisia typografisia sääntöjä parantamaan tekstikappaleiden luettavuutta, (c), (tm) ym. korvataan symboleilla, kysymysmerkkien määrää vähennetään jne." + allow_html_tables: "Salli taulukoiden syöttäminen Markdowniin käyttäen HTML tageja. TABLE, THEAD, TD, TR, TH valkolistataan (edellyttää kaikkien taulukoita sisältävien vanhojen viestien uudelleen rakentamisen)" post_undo_action_window_mins: "Kuinka monta minuuttia käyttäjällä on aikaa perua viestiin kohdistuva toimi (tykkäys, liputus, etc)." must_approve_users: "Henkilökunnan täytyy hyväksyä kaikki uudet tilit, ennen uusien käyttäjien päästämistä sivustolle. VAROITUS: tämän asetuksen valitseminen poistaa pääsyn kaikilta jo olemassa olevilta henkilökuntaan kuulumattomilta käyttäjiltä." pending_users_reminder_delay: "Ilmoita valvojille, jos uusi käyttäjä on odottanut hyväksyntää kauemmin kuin näin monta tuntia. Aseta -1, jos haluat kytkeä ilmoitukset pois päältä." diff --git a/config/locales/server.fr.yml b/config/locales/server.fr.yml index cd44c0a4586..2b85e006886 100644 --- a/config/locales/server.fr.yml +++ b/config/locales/server.fr.yml @@ -931,6 +931,7 @@ fr: notify_mods_when_user_blocked: "Si un utilisateur est bloqué automatiquement, envoyer un message à tous les modérateurs." flag_sockpuppets: "Si un nouvel utilisateur répond à un sujet avec la même adresse IP que le nouvel utilisateur qui a commencé le sujet, alors leurs messages seront automatiquement marqués comme spam." traditional_markdown_linebreaks: "Utiliser le retour à la ligne traditionnel dans Markdown, qui nécessite deux espaces pour un saut de ligne." + allow_html_tables: "Autoriser la saisie des tableaux dans le Markdown en utilisant les tags HTML : TABLE, THEAD, TD, TR, TH sont autorisés (nécessite un rebake de tous les anciens messages contenant des tableaux)" post_undo_action_window_mins: "Nombre de minutes pendant lesquelles un utilisateur peut annuler une action sur un message (J'aime, signaler, etc.)" must_approve_users: "Les responsables doivent approuver les nouveaux utilisateurs afin qu'ils puissent accéder au site. ATTENTION : activer cette option sur un site en production suspendra l'accès des utilisateurs existants qui ne sont pas des responsables !" pending_users_reminder_delay: "Avertir les modérateurs si des nouveaux utilisateurs sont en attente d'approbation depuis x heures. Mettre -1 pour désactiver les notifications." diff --git a/config/locales/server.he.yml b/config/locales/server.he.yml index d809362bdc1..5795be1d51c 100644 --- a/config/locales/server.he.yml +++ b/config/locales/server.he.yml @@ -921,6 +921,7 @@ he: notify_mods_when_user_blocked: "אם משתמש נחסם אוטומטית, שילחו הודעה לכל המנחים." flag_sockpuppets: "אם משתמשים חדשים מגיבים לנושא מכתובת IP זהה לזו של מי שהחל את הנושא, סמנו את הפוסטים של שניהם כספאם פוטנציאלי." traditional_markdown_linebreaks: "שימוש בשבירת שורות מסורתית בסימון, מה שדורש שני רווחים עוקבים למעבר שורה." + allow_html_tables: "אפשרו הכנסת טבלאות ב Markdown באמצעות תגיות HTML. התגיות TABLE, THEAD, TD, TR, TH יהיו ברשימה לבנה (מצריך אפייה מחדש של כל הפוסטים הישנים שכוללים טבלאות)" post_undo_action_window_mins: "מספר הדקות בהן מתאפשר למשתמשים לבטל פעולות אחרות בפוסט (לייק, סימון, וכו')." must_approve_users: "על הצוות לאשר את כל המשתמשים החדשים לפני שהם מקבלים גישה לאתר. אזהרה: בחירה זו עבור אתר קיים תשלול גישה ממשתמשים קיימים שאינם מנהלים." pending_users_reminder_delay: "הודיעו למנחים אם משתמשים חדשים ממתינים לאישור למעלה מכמות זו של שעות. קבעו ל -1 כדי לנטרל התראות." diff --git a/config/locales/server.it.yml b/config/locales/server.it.yml index ad31c6d81b5..722c723d096 100644 --- a/config/locales/server.it.yml +++ b/config/locales/server.it.yml @@ -934,6 +934,7 @@ it: notify_mods_when_user_blocked: "Se un utente è bloccato automaticamente, manda un messaggio ai moderatori." flag_sockpuppets: "Se un nuovo utente risponde ad un argomento dallo stesso indirizzo IP dell'utente che ha aperto l'argomento stesso, segnala entrambi i messaggi come potenziale spam." traditional_markdown_linebreaks: "Usa l'accapo tradizionale in Markdown, cioè due spazi a fine riga per andare a capo." + allow_html_tables: "Consenti di inserire tabelle in Markdown usando tag HTML. I tag TABLE, THEAD, TD, TR, TH saranno consentiti (richiede un full rebake di tutti i vecchi messaggi contenenti tabelle)" post_undo_action_window_mins: "Numero di minuti durante i quali gli utenti possono annullare le loro azioni recenti su un messaggio (segnalazioni, Mi piace, ecc.)." must_approve_users: "Lo staff deve approvare tutti i nuovi account utente prima che essi possano accedere al sito. ATTENZIONE: abilitare l'opzione per un sito live revocherà l'accesso per tutti gli utenti non-staff esistenti!" pending_users_reminder_delay: "Notifica i moderatori se nuovi utenti sono in attesa di approvazione per più di queste ore. Imposta a -1 per disabilitare le notifiche." diff --git a/config/locales/server.ko.yml b/config/locales/server.ko.yml index 2bffa0478e7..30f6b772cda 100644 --- a/config/locales/server.ko.yml +++ b/config/locales/server.ko.yml @@ -876,7 +876,9 @@ ko: notify_mods_when_user_blocked: "만약 사용자가 자동 블락되면 중간 운영자에게 메시지 보내기" flag_sockpuppets: "어떤 신규 사용자(예:24이내 가입자)가 글타래를 생성하고 같은 IP주소의 또 다른 신규 사용자가 댓글을 쓰면 자동 스팸 신고" traditional_markdown_linebreaks: "Markdown에서 전통적인 linebreak를 사용, linebreak시 두개의 trailing space를 사용하는 것." + enable_experimental_markdown_it: "(실험) CommonMark를 지원하는 markdown.it 엔진을 사용합니다. 경고: 올바르게 작동하지 않는 플러그인이 있을 수 있습니다." enable_markdown_typographer: "문단의 가독성을 높이기 위해서 기본 타이포그라피 룰을 사용합니다. (c) (tm), 기타 기호를 교체하고 연달아 나오는 물음표의 갯수를 줄입니다." + allow_html_tables: "마크다운 문서에 HTML 테이블을 허용합니다. TABLE, THEAD, TD, TR, TH 태그를 사용할 수 있습니다.(테이블이 포함된 이전 게시물에 적용하려면 rebake 해야 합니다.)" post_undo_action_window_mins: "사용자가 어떤 글에 대해서 수행한 작업(신고 등)을 취소하는 것이 허용되는 시간(초)" must_approve_users: "스태프는 반드시 사이트 엑세스권한을 허용하기 전에 모든 신규가입계정을 승인해야 합니다. 경고: 이것을 활성화하면 기존 스태프 아닌 회원들의 엑세스권한이 회수됩니다." pending_users_reminder_delay: "새로운 사용자가 승인을 기다리는 시간이 여기에 지정된 시간횟수보다 더 길어길경우 운영자에게 알려줍니다. 알림을 해제하려면 -1로 설정하세요." diff --git a/config/locales/server.nl.yml b/config/locales/server.nl.yml index 828a8c074ba..c28ee211c71 100644 --- a/config/locales/server.nl.yml +++ b/config/locales/server.nl.yml @@ -895,6 +895,7 @@ nl: notify_mods_when_user_blocked: "Als een gebruiker automatisch geblokkeerd is, stuur dan een bericht naar alle moderatoren." flag_sockpuppets: "Als een nieuwe gebruiker antwoord op een topic vanaf hetzelfde ip-adres als de nieuwe gebruiker die het topic opende, markeer dan beide berichten als potentiële spam." traditional_markdown_linebreaks: "Gebruik traditionele regeleinden in Markdown, welke 2 spaties aan het einde van een regel nodig heeft voor een regeleinde." + allow_html_tables: "Sta toe dat tabellen in Markdown mogen worden ingevoerd met behulp van HTML-tags. TABLE, TD, TR, TH zullen aan de whitelist worden toegevoegd (vereist volledig herbouwen van alle oude berichten met tabellen)" post_undo_action_window_mins: "Het aantal minuten waarin gebruikers hun recente acties op een bericht nog terug kunnen draaien (liken, markeren, etc)." must_approve_users: "Stafleden moeten alle nieuwe gebruikersaccounts goedkeuren voordat ze de site mogen bezoeken. OPGELET: als dit wordt aangezet voor een actieve site wordt alle toegang voor bestaande niet stafleden ingetrokken." pending_users_reminder_delay: "Moderators informeren als nieuwe gebruikers al langer dan dit aantal uren op goedkeuring wachten. Stel dit in op -1 om meldingen uit te schakelen." diff --git a/config/locales/server.pl_PL.yml b/config/locales/server.pl_PL.yml index bdf16364395..67ddc8bdf67 100644 --- a/config/locales/server.pl_PL.yml +++ b/config/locales/server.pl_PL.yml @@ -956,6 +956,7 @@ pl_PL: notify_mods_when_user_blocked: "If a user is automatically blocked, send a message to all moderators." flag_sockpuppets: "Jeśli nowy użytkownik odpowiada na dany temat z tego samego adresu IP co nowy użytkownik, który założył temat, oznacz ich posty jako potencjalny spam." traditional_markdown_linebreaks: "Używaj tradycyjnych znaków końca linii w Markdown, to znaczy dwóch spacji na końcu linii." + allow_html_tables: "Pozwalaj tabelom być zamieszczanym w Markdown przy użyciu tagów HTML. TABLE, THEAD, TD, TR, TH będą dozwolone (wymaga pełnego rebake na wszystkich starych postach zawierających tabele)." post_undo_action_window_mins: "Przez tyle minut użytkownicy mogą cofnąć swoje ostatnie działania przy danym poście (lajki, flagowanie, itd.)." must_approve_users: "Zespół musi zaakceptować wszystkie nowe konta zanim uzyskają dostęp do serwisu. UWAGA: włączenie tego dla już udostępnionej strony sprawi, że zostanie odebrany dostęp wszystkim istniejącym użytkownikom spoza zespołu." pending_users_reminder_delay: "Powiadomić moderatorów jeżeli nowi użytkownicy czekali na zatwierdzenie dłużej niż his mamy godzin. Ustaw -1 aby wyłączyć powiadomienia. " diff --git a/config/locales/server.pt.yml b/config/locales/server.pt.yml index 3f859154d67..a89dda5a2da 100644 --- a/config/locales/server.pt.yml +++ b/config/locales/server.pt.yml @@ -802,6 +802,7 @@ pt: notify_mods_when_user_blocked: "Se um utilizador for bloqueado de forma automática, enviar uma mensagem a todos os moderadores." flag_sockpuppets: "Se um novo utilizador responde a um tópico a partir do mesmo endereço IP do novo utilizador que iniciou o tópico, sinalizar ambas as mensagens como potencial spam." traditional_markdown_linebreaks: "Utilize tradicionais quebras de linha no Markdown, que requer dois espaços no final para uma quebra de linha." + allow_html_tables: "Permitir inserção de tabelas em Markdown utilizando tags HTML. TABLE,THEAD, TD, TR,TH fazem parte da lista branca (requer que todas as mensagens antigas que contém tabelas sejam refeitas)" post_undo_action_window_mins: "Número de minutos durante o qual os utilizadores têm permissão para desfazer ações numa mensagem (gostos, sinalizações, etc)." must_approve_users: "O pessoal deve aprovar todas as novas contas de utilizador antes destas terem permissão para aceder ao sítio. AVISO: ativar isto para um sítio ativo irá revogar o acesso aos utilizadores existentes que não fazem parte do pessoal!" pending_users_reminder_delay: "Notificar moderadores se novos utilizadores estiverem à espera de aprovação por mais que esta quantidade de horas. Configurar com -1 para desativar notificações." diff --git a/config/locales/server.ro.yml b/config/locales/server.ro.yml index e4413b92537..8082e409bf1 100644 --- a/config/locales/server.ro.yml +++ b/config/locales/server.ro.yml @@ -878,6 +878,7 @@ ro: notify_mods_when_user_blocked: "Dacă un utilizator este blocat automat, trimite un mesaj tuturor moderatorilor." flag_sockpuppets: "Dacă un utilizator nou răspunde unui subiect de la același IP ca utilizatorul ce a pornit subiectul, marchează ambele postări ca potențial spam." traditional_markdown_linebreaks: "Folosește întreruperi de rând tradiționale în Markdown, ceea ce necesită două spații pentru un capăt de rând. " + allow_html_tables: "Permite introducerea de tabele în Markdown prin folosirea de etichete HTML. HEAD, TD, TR, TH vor fi autorizate (necesită un rebake pe toate postările vechi ce conțin tabele)" post_undo_action_window_mins: "Numărul de minute în care utilizatorii pot anula acțiunile recente asupra unei postări (aprecieri, marcări cu marcaje de avertizare, etc)." must_approve_users: "Membrii echipei trebuie să aprobe toate conturile noilor utilizatori înainte ca aceștia să poată accesa site-ul. ATENȚIE: activarea acestei opțiuni pentru un site în producție va revoca accesul tuturor utilizatorilor care nu sunt membri ai echipei!" pending_users_reminder_delay: "Notifică moderatorii dacă noii utilizatori sunt în așteptarea aprobării de mai mult de atâtea ore. Setează la -1 pentru a dezactiva notificările." diff --git a/config/locales/server.sk.yml b/config/locales/server.sk.yml index 912d3d5da07..71cd2e89a39 100644 --- a/config/locales/server.sk.yml +++ b/config/locales/server.sk.yml @@ -761,6 +761,7 @@ sk: notify_mods_when_user_blocked: "Ak je používateľ automaticky zablokovaný, pošli správu všetkým moderátorom." flag_sockpuppets: "Ak nový používateľ odpovedá na tému z rovnakej IP adresy, ako nový používateľ, ktorý danú tému vytvoril, označ oba ich príspevky ako potencionálny spam." traditional_markdown_linebreaks: "V Markdown použiť tradičné oddeľovače riadkov, čo vyžaduje dve koncové medzery ako oddeľovač riadku." + allow_html_tables: "V Markdown umožniť použitie tabuliek pomocou HTML značiek. TABLE, THEAD, TD, TR, TH budú umožnené (vyžaduje \"full rebake\" na všetkých starých príspevkoch ktoré obsahujú tabuľky)" post_undo_action_window_mins: "Počet minút počas ktorých môžu používatelia zrušiť poslednú akciu na príspevku (\"Páči sa\", označenie, atď..)." must_approve_users: "Obsluha musí povoliť účty všetkým novým používateľom skôr než im bude povolený prístup na stránku. UPOZORNENIE: zapnutie na živej stránke spôsobí zrušenie prístupu pre existujúcich používateľov, okrem obsluhy!" pending_users_reminder_delay: "Upozorni moderátora ak nový používateľ čaká na schválenie dlhšie ako tento počet hodín. Nastavte -1 pre vypnutie upozornenia." diff --git a/config/locales/server.sv.yml b/config/locales/server.sv.yml index e6242939a3d..6ac8c593d3e 100644 --- a/config/locales/server.sv.yml +++ b/config/locales/server.sv.yml @@ -818,6 +818,7 @@ sv: notify_mods_when_user_blocked: "Om en användare blockeras automatiskt, skicka ett meddelande till alla moderatorer." flag_sockpuppets: "Flagga båda användarnas inlägg som potentiell skräppost om en ny användare svarar på ett ämne från samma IP-adress som den andra nya användaren som skapade ämnet." traditional_markdown_linebreaks: "Använd vanliga radmatningar i Markdown, vilka kräver 2 avslutande mellanslag för en radmatning." + allow_html_tables: "Tillåt tabeller att läggas in i Markdown genom användning av HTML-taggar. TABLE, THEAD, TD, TR, TH kommer att vitlistas (kräver full uppdatering/rebake av alla gamla inlägg som innehåller tabeller)" post_undo_action_window_mins: "Antal minuter som en användare tillåts att upphäva handlingar på ett inlägg som gjorts nyligen (gillning, flaggning osv)." must_approve_users: "Personal måste godkänna alla nya användarkonton innan de tillåts använda webbplatsen. VARNING: om det tillåts när webbplatsen är live så kommer det att upphäva tillgång för alla existerande användare som inte är personal!" pending_users_reminder_delay: "Notifiera moderatorer om nya användare har väntat på godkännande längre än så här många timmar. Ange -1 för att inaktivera notifikationer. " diff --git a/config/locales/server.tr_TR.yml b/config/locales/server.tr_TR.yml index 1e9ec205cb9..0430d47a47d 100644 --- a/config/locales/server.tr_TR.yml +++ b/config/locales/server.tr_TR.yml @@ -705,6 +705,7 @@ tr_TR: notify_mods_when_user_blocked: "Eğer bir kullanıcı otomatik olarak engellendiyse, tüm moderatörlere ileti yolla." flag_sockpuppets: "Eğer, yeni kullanıcı konuya, konuyu başlatan yeni kullanıcı ile aynı IP adresinden cevap yazarsa, her iki gönderiyi de potansiyel istenmeyen olarak bildir. " traditional_markdown_linebreaks: "Markdown'da, satır sonundan önce yazının sağında iki tane boşluk gerektiren, geleneksel satır sonu metodunu kullan." + allow_html_tables: "Tabloların HTML etiketleri kullanılarak Markdown ile oluşturulmasına izin verin. TABLE, THEAD, TD, TR, TH kabul edilir (tablo içeren tüm eski gönderilerin yenilenmesini gerektirir) " post_undo_action_window_mins: "Bir gönderide yapılan yeni eylemlerin (beğenme, bildirme vb) geri alınabileceği zaman, dakika olarak" must_approve_users: "Siteye erişimlerine izin verilmeden önce tüm yeni kullanıcı hesaplarının görevliler tarafından onaylanması gerekir. UYARI: yayındaki bir site için bunu etkinleştirmek görevli olmayan hesapların erişimini iptal edecek." pending_users_reminder_delay: "Belirtilen saatten daha uzun bir süredir onay bekleyen yeni kullanıcılar mevcutsa moderatörleri bilgilendir. Bilgilendirmeyi devre dışı bırakmak için -1 girin." diff --git a/config/locales/server.vi.yml b/config/locales/server.vi.yml index 346f3e56281..62f13094af1 100644 --- a/config/locales/server.vi.yml +++ b/config/locales/server.vi.yml @@ -681,6 +681,7 @@ vi: notify_mods_when_user_blocked: "Nếu một thành viên được khóa tự động, gửi tin nhắn đến tất cả các điều hành viên." flag_sockpuppets: "Nếu thành viên mới trả lời chủ đề có cùng địa chỉ IP với thành viên mới tạo chủ đề, đánh dấu các bài viết của họ là spam tiềm năng." traditional_markdown_linebreaks: "Sử dụng ngắt dòng truyền thống trong Markdown, đòi hỏi hai khoảng trống kế tiếp cho một ngắt dòng." + allow_html_tables: "Cho phép nhập bảng trong Markdown sử dụng các thẻ HTML. TABLE, THEAD, TD, TR, TH sẽ được sử dụng (đòi hỏi thực hiện lại cho các bài viết cũ có chứa bảng)" post_undo_action_window_mins: "Số phút thành viên được phép làm lại các hành động gần đây với bài viết (like, đánh dấu...)." must_approve_users: "Quản trị viên phải duyệt tất cả các tài khoản thành viên mới trước khi họ có quyền truy cập website. LƯU Ý: bật tính năng này trên site đang hoạt động sẽ hủy bỏ quyền truy cập đối với các tài khoản thành viên hiện tại!" pending_users_reminder_delay: "Thông báo cho quản trị viên nếu thành viên mới đã chờ duyệt lâu hơn số giờ được thiết lập ở đây, đặt là -1 để tắt thông báo." diff --git a/config/locales/server.zh_CN.yml b/config/locales/server.zh_CN.yml index b81aabde716..297f1c3e334 100644 --- a/config/locales/server.zh_CN.yml +++ b/config/locales/server.zh_CN.yml @@ -881,6 +881,7 @@ zh_CN: notify_mods_when_user_blocked: "如果一个用户被自动封禁了,发送一个私信给所有管理员。" flag_sockpuppets: "如果一个新用户开始了一个主题,并且同时另一个新用户以同一个 IP 在该主题回复,他们所有的帖子都将被自动标记为垃圾。" traditional_markdown_linebreaks: "在 Markdown 中使用传统换行符,即用两个尾随空格来换行" + allow_html_tables: "允许在输入 Markdown 文本时使用表格 HTML 标签。标签 TABLE、THEAD、TD、TR、TH 将被允许使用,即白名单这些标签(需要重置所有包含表格的老帖子的 HTML)" post_undo_action_window_mins: "允许用户在帖子上进行撤销操作(赞、标记等)所需等待的间隔分钟数" must_approve_users: "新用户在被允许访问站点前需要由管理人员批准。警告:在运行的站点中启用将解除所有非管理人员用户的访问权限!" pending_users_reminder_delay: "如果新用户等待批准时间超过此小时设置则通知版主。设置 -1 关闭通知。" diff --git a/config/locales/server.zh_TW.yml b/config/locales/server.zh_TW.yml index 16962647e74..ebd13f4af56 100644 --- a/config/locales/server.zh_TW.yml +++ b/config/locales/server.zh_TW.yml @@ -832,6 +832,7 @@ zh_TW: notify_mods_when_user_blocked: "若有用戶被自動封鎖,將發送訊息給所有板主。" flag_sockpuppets: "如果一個新用戶開始了一個主題,並且同時另一個新用戶以同一個 IP 在該主題回復,他們所有的帖子都將被自動標記為垃圾。" traditional_markdown_linebreaks: "在 Markdown 中使用傳統的換行符號,即用兩個行末空格來換行" + allow_html_tables: "允許在輸入 Markdown 文本時使用表格 HTML 標籤。標籤 TABLE、THEAD、TD、TR、TH 將被允許使用,即白名單這些標籤(需要重置所有包含表格的老帖子的 HTML)" post_undo_action_window_mins: "允許用戶在帖子上進行撤銷操作(讚、標記等)所需等待的時間分隔(分鐘)" must_approve_users: "新用戶在被允許訪問站點前需要由管理人員批准。警告:在運行的站點中啟用將解除所有非管理人員用戶的訪問權限!" pending_users_reminder_delay: "如果新用戶等待批准時間超過此小時設置則通知版主。設置 -1 關閉通知。" diff --git a/config/site_settings.yml b/config/site_settings.yml index 7d03976ad48..9816e822ce1 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -492,12 +492,19 @@ posting: delete_removed_posts_after: client: true default: 24 + enable_experimental_markdown_it: + client: true + default: false + shadowed_by_global: true traditional_markdown_linebreaks: client: true default: false enable_markdown_typographer: client: true - default: true + default: false + allow_html_tables: + client: true + default: false suppress_reply_directly_below: client: true default: true diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index 0fefc15b6e3..2b9b3489b78 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -80,7 +80,11 @@ module PrettyText ctx_load(ctx, "#{Rails.root}/app/assets/javascripts/discourse-loader.js") ctx_load(ctx, "vendor/assets/javascripts/lodash.js") ctx_load_manifest(ctx, "pretty-text-bundle.js") - ctx_load_manifest(ctx, "markdown-it-bundle.js") + + if SiteSetting.enable_experimental_markdown_it + ctx_load_manifest(ctx, "markdown-it-bundle.js") + end + root_path = "#{Rails.root}/app/assets/javascripts/" apply_es6_file(ctx, root_path, "discourse/lib/utilities") @@ -148,6 +152,13 @@ module PrettyText paths[:S3BaseUrl] = Discourse.store.absolute_base_url end + if SiteSetting.enable_experimental_markdown_it + # defer load markdown it + unless context.eval("window.markdownit") + ctx_load_manifest(context, "markdown-it-bundle.js") + end + end + custom_emoji = {} Emoji.custom.map { |e| custom_emoji[e.name] = e.url } diff --git a/lib/pretty_text/shims.js b/lib/pretty_text/shims.js index 93a1ef592fd..b3efe6cdf54 100644 --- a/lib/pretty_text/shims.js +++ b/lib/pretty_text/shims.js @@ -7,6 +7,8 @@ __utils = require('discourse/lib/utilities'); __emojiUnicodeReplacer = null; __setUnicode = function(replacements) { + require('pretty-text/engines/discourse-markdown/emoji').setUnicodeReplacements(replacements); + let unicodeRegexp = new RegExp(Object.keys(replacements).sort().reverse().join("|"), "g"); __emojiUnicodeReplacer = function(text) { diff --git a/script/import_scripts/lithium.rb b/script/import_scripts/lithium.rb index 54cd27e1d92..bd0a6f00553 100644 --- a/script/import_scripts/lithium.rb +++ b/script/import_scripts/lithium.rb @@ -53,6 +53,7 @@ class ImportScripts::Lithium < ImportScripts::Base def execute + SiteSetting.allow_html_tables = true @max_start_id = Post.maximum(:id) import_categories diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index 49bf5db18ad..ea6c749d5d6 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -5,7 +5,7 @@ require 'html_normalize' describe PrettyText do before do - SiteSetting.enable_markdown_typographer = false + SiteSetting.enable_experimental_markdown_it = true end def n(html) diff --git a/test/javascripts/lib/pretty-text-test.js.es6 b/test/javascripts/lib/pretty-text-test.js.es6 index 19ba8bc66c7..558c105e3fd 100644 --- a/test/javascripts/lib/pretty-text-test.js.es6 +++ b/test/javascripts/lib/pretty-text-test.js.es6 @@ -42,6 +42,9 @@ QUnit.assert.cookedPara = function(input, expected, message) { }; QUnit.test("buildOptions", assert => { + assert.ok(buildOptions({ siteSettings: { allow_html_tables: true } }).features.table, 'tables enabled'); + assert.ok(!buildOptions({ siteSettings: { allow_html_tables: false } }).features.table, 'tables disabled'); + assert.ok(buildOptions({ siteSettings: { enable_emoji: true } }).features.emoji, 'emoji enabled'); assert.ok(!buildOptions({ siteSettings: { enable_emoji: false } }).features.emoji, 'emoji disabled'); }); diff --git a/test/javascripts/models/model-test.js.es6 b/test/javascripts/models/model-test.js.es6 index dba22bd390a..eb4d47b8d1c 100644 --- a/test/javascripts/models/model-test.js.es6 +++ b/test/javascripts/models/model-test.js.es6 @@ -18,4 +18,4 @@ QUnit.test("extractByKey: converts a list of hashes into a hash of instances of QUnit.test("extractByKey: returns an empty hash if there isn't anything to convert", assert => { assert.deepEqual(Model.extractByKey(), {}, "when called without parameters"); assert.deepEqual(Model.extractByKey([]), {}, "when called with an empty array"); -}); +}); \ No newline at end of file diff --git a/vendor/assets/javascripts/better_markdown.js b/vendor/assets/javascripts/better_markdown.js new file mode 100644 index 00000000000..836b1c7e313 --- /dev/null +++ b/vendor/assets/javascripts/better_markdown.js @@ -0,0 +1,1518 @@ +/* + This is a fork of markdown-js with a few changes to support discourse: + + * We have replaced the strong/em handlers because we prefer them only to work on word + boundaries. + + * [MOD]: non-url is fixed + + // Fix code within attrs + if (prev && (typeof prev[0] === "string") && prev[0].match(/<[^>]+$/)) { return; } + + // __RAW + + // if ( next_block.match(is_list_re) || (next_block.match(/^ /) && (!next_block.match(/^ *\>/))) ) { + +*/ + +// Released under MIT license +// Copyright (c) 2009-2010 Dominic Baggott +// Copyright (c) 2009-2010 Ash Berlin +// Copyright (c) 2011 Christoph Dorn (http://www.christophdorn.com) + +/*jshint browser:true, devel:true */ + +(function(expose) { + + var MarkdownHelpers = {}; + + // For Spidermonkey based engines + function mk_block_toSource() { + return "Markdown.mk_block( " + + uneval(this.toString()) + + ", " + + uneval(this.trailing) + + ", " + + uneval(this.lineNumber) + + " )"; + } + + // node + function mk_block_inspect() { + var util = require("util"); + return "Markdown.mk_block( " + + util.inspect(this.toString()) + + ", " + + util.inspect(this.trailing) + + ", " + + util.inspect(this.lineNumber) + + " )"; + + } + + MarkdownHelpers.mk_block = function(block, trail, line) { + // Be helpful for default case in tests. + if ( arguments.length === 1 ) + trail = "\n\n"; + + // We actually need a String object, not a string primitive + /* jshint -W053 */ + var s = new String(block); + s.trailing = trail; + // To make it clear its not just a string + s.inspect = mk_block_inspect; + s.toSource = mk_block_toSource; + + if ( line !== undefined ) + s.lineNumber = line; + + return s; + }; + + var isArray = MarkdownHelpers.isArray = Array.isArray || function(obj) { + return Object.prototype.toString.call(obj) === "[object Array]"; + }; + + // Don't mess with Array.prototype. Its not friendly + if ( Array.prototype.forEach ) { + MarkdownHelpers.forEach = function forEach( arr, cb, thisp ) { + return arr.forEach( cb, thisp ); + }; + } + else { + MarkdownHelpers.forEach = function forEach(arr, cb, thisp) { + for (var i = 0; i < arr.length; i++) + cb.call(thisp || arr, arr[i], i, arr); + }; + } + + MarkdownHelpers.isEmpty = function isEmpty( obj ) { + for ( var key in obj ) { + if ( hasOwnProperty.call( obj, key ) ) + return false; + } + return true; + }; + + MarkdownHelpers.extract_attr = function extract_attr( jsonml ) { + return isArray(jsonml) + && jsonml.length > 1 + && typeof jsonml[ 1 ] === "object" + && !( isArray(jsonml[ 1 ]) ) + ? jsonml[ 1 ] + : undefined; + }; + + /** + * class Markdown + * + * Markdown processing in Javascript done right. We have very particular views + * on what constitutes 'right' which include: + * + * - produces well-formed HTML (this means that em and strong nesting is + * important) + * + * - has an intermediate representation to allow processing of parsed data (We + * in fact have two, both as [JsonML]: a markdown tree and an HTML tree). + * + * - is easily extensible to add new dialects without having to rewrite the + * entire parsing mechanics + * + * - has a good test suite + * + * This implementation fulfills all of these (except that the test suite could + * do with expanding to automatically run all the fixtures from other Markdown + * implementations.) + * + * ##### Intermediate Representation + * + * *TODO* Talk about this :) Its JsonML, but document the node names we use. + * + * [JsonML]: http://jsonml.org/ "JSON Markup Language" + **/ + var Markdown = function(dialect) { + switch (typeof dialect) { + case "undefined": + this.dialect = Markdown.dialects.Gruber; + break; + case "object": + this.dialect = dialect; + break; + default: + if ( dialect in Markdown.dialects ) + this.dialect = Markdown.dialects[dialect]; + else + throw new Error("Unknown Markdown dialect '" + String(dialect) + "'"); + break; + } + this.em_state = []; + this.strong_state = []; + this.debug_indent = ""; + }; + + /** + * Markdown.dialects + * + * Namespace of built-in dialects. + **/ + Markdown.dialects = {}; + + // Imported functions + var mk_block = Markdown.mk_block = MarkdownHelpers.mk_block, + isArray = MarkdownHelpers.isArray; + + /** + * parse( markdown, [dialect] ) -> JsonML + * - markdown (String): markdown string to parse + * - dialect (String | Dialect): the dialect to use, defaults to gruber + * + * Parse `markdown` and return a markdown document as a Markdown.JsonML tree. + **/ + Markdown.parse = function( source, dialect ) { + // dialect will default if undefined + var md = new Markdown( dialect ); + return md.toTree( source ); + }; + + /** + * count_lines( str ) -> count + * - str (String): String whose lines we want to count + * + * Counts the number of linebreaks in `str` + **/ + function count_lines( str ) { + return str.split("\n").length - 1; + } + + // Internal - split source into rough blocks + Markdown.prototype.split_blocks = function splitBlocks( input ) { + input = input.replace(/(\r\n|\n|\r)/g, "\n"); + // [\s\S] matches _anything_ (newline or space) + // [^] is equivalent but doesn't work in IEs. + var re = /([\s\S]+?)($|\n#|\n(?:\s*\n|$)+)/g, + blocks = [], + m; + + var line_no = 1; + + if ( ( m = /^(\s*\n)/.exec(input) ) !== null ) { + // skip (but count) leading blank lines + line_no += count_lines( m[0] ); + re.lastIndex = m[0].length; + } + + while ( ( m = re.exec(input) ) !== null ) { + if (m[2] === "\n#") { + m[2] = "\n"; + re.lastIndex--; + } + blocks.push( mk_block( m[1], m[2], line_no ) ); + line_no += count_lines( m[0] ); + } + + return blocks; + }; + + /** + * Markdown#processBlock( block, next ) -> undefined | [ JsonML, ... ] + * - block (String): the block to process + * - next (Array): the following blocks + * + * Process `block` and return an array of JsonML nodes representing `block`. + * + * It does this by asking each block level function in the dialect to process + * the block until one can. Succesful handling is indicated by returning an + * array (with zero or more JsonML nodes), failure by a false value. + * + * Blocks handlers are responsible for calling [[Markdown#processInline]] + * themselves as appropriate. + * + * If the blocks were split incorrectly or adjacent blocks need collapsing you + * can adjust `next` in place using shift/splice etc. + * + * If any of this default behaviour is not right for the dialect, you can + * define a `__call__` method on the dialect that will get invoked to handle + * the block processing. + */ + Markdown.prototype.processBlock = function processBlock( block, next ) { + var cbs = this.dialect.block, + ord = cbs.__order__; + + if ( "__call__" in cbs ) + return cbs.__call__.call(this, block, next); + + for ( var i = 0; i < ord.length; i++ ) { + //D:this.debug( "Testing", ord[i] ); + var res = cbs[ ord[i] ].call( this, block, next ); + if ( res ) { + //D:this.debug(" matched"); + if ( !isArray(res) || ( res.length > 0 && !( isArray(res[0]) ) && ( typeof res[0] !== "string")) ) + this.debug(ord[i], "didn't return a proper array"); + //D:this.debug( "" ); + return res; + } + } + + // Uhoh! no match! Should we throw an error? + return []; + }; + + Markdown.prototype.processInline = function processInline( block ) { + return this.dialect.inline.__call__.call( this, String( block ) ); + }; + + /** + * Markdown#toTree( source ) -> JsonML + * - source (String): markdown source to parse + * + * Parse `source` into a JsonML tree representing the markdown document. + **/ + // custom_tree means set this.tree to `custom_tree` and restore old value on return + Markdown.prototype.toTree = function toTree( source, custom_root ) { + var blocks = source instanceof Array ? source : this.split_blocks( source ); + + // Make tree a member variable so its easier to mess with in extensions + var old_tree = this.tree; + try { + this.tree = custom_root || this.tree || [ "markdown" ]; + + blocks_loop: + while ( blocks.length ) { + var b = this.processBlock( blocks.shift(), blocks ); + + // Reference blocks and the like won't return any content + if ( !b.length ) + continue blocks_loop; + + this.tree.push.apply( this.tree, b ); + } + return this.tree; + } + finally { + if ( custom_root ) + this.tree = old_tree; + } + }; + + // Noop by default + Markdown.prototype.debug = function () { + var args = Array.prototype.slice.call( arguments); + args.unshift(this.debug_indent); + if ( typeof print !== "undefined" ) + print.apply( print, args ); + if ( typeof console !== "undefined" && typeof console.log !== "undefined" ) + console.log.apply( null, args ); + }; + + Markdown.prototype.loop_re_over_block = function( re, block, cb ) { + // Dont use /g regexps with this + var m, + b = block.valueOf(); + + while ( b.length && (m = re.exec(b) ) !== null ) { + b = b.substr( m[0].length ); + cb.call(this, m); + } + return b; + }; + + // Build default order from insertion order. + Markdown.buildBlockOrder = function(d) { + var ord = [[]]; + for ( var i in d ) { + if ( i === "__order__" || i === "__call__" ) + continue; + + var priority = d[i].priority || 0; + ord[priority] = ord[priority] || []; + ord[priority].push( i ); + } + + var flattend = []; + for (i=ord.length-1; i>=0; i--){ + if (ord[i]) { + for (var j=0; j String + * - jsonml (Array): JsonML array to render to XML + * - options (Object): options + * + * Converts the given JsonML into well-formed XML. + * + * The options currently understood are: + * + * - root (Boolean): wether or not the root node should be included in the + * output, or just its children. The default `false` is to not include the + * root itself. + */ + Markdown.renderJsonML = function( jsonml, options ) { + options = options || {}; + // include the root element in the rendered output? + options.root = options.root || false; + + var content = []; + + if ( options.root ) { + content.push( render_tree( jsonml ) ); + } + else { + jsonml.shift(); // get rid of the tag + if ( jsonml.length && typeof jsonml[ 0 ] === "object" && !( jsonml[ 0 ] instanceof Array ) ) + jsonml.shift(); // get rid of the attributes + + while ( jsonml.length ) + content.push( render_tree( jsonml.shift() ) ); + } + + return content.join( "\n\n" ); + }; + + /** + * toHTMLTree( markdown, [dialect] ) -> JsonML + * toHTMLTree( md_tree ) -> JsonML + * - markdown (String): markdown string to parse + * - dialect (String | Dialect): the dialect to use, defaults to gruber + * - md_tree (Markdown.JsonML): parsed markdown tree + * + * Turn markdown into HTML, represented as a JsonML tree. If a string is given + * to this function, it is first parsed into a markdown tree by calling + * [[parse]]. + **/ + Markdown.toHTMLTree = function toHTMLTree( input, dialect , options ) { + + // convert string input to an MD tree + if ( typeof input === "string" ) + input = this.parse( input, dialect ); + + // Now convert the MD tree to an HTML tree + + // remove references from the tree + var attrs = extract_attr( input ), + refs = {}; + + if ( attrs && attrs.references ) + refs = attrs.references; + + + var html = convert_tree_to_html( input, refs , options ); + merge_text_nodes( html ); + return html; + }; + + /** + * toHTML( markdown, [dialect] ) -> String + * toHTML( md_tree ) -> String + * - markdown (String): markdown string to parse + * - md_tree (Markdown.JsonML): parsed markdown tree + * + * Take markdown (either as a string or as a JsonML tree) and run it through + * [[toHTMLTree]] then turn it into a well-formated HTML fragment. + **/ + Markdown.toHTML = function toHTML( source , dialect , options ) { + var input = this.toHTMLTree( source , dialect , options ); + + return this.renderJsonML( input ); + }; + + function escapeHTML( text ) { + if (text && text.length > 0) { + return text.replace( /&/g, "&" ) + .replace( //g, ">" ) + .replace( /"/g, """ ) + .replace( /'/g, "'" ); + } else { + return ""; + } + } + + function render_tree( jsonml ) { + // basic case + if ( typeof jsonml === "string" ) + return jsonml; + + if ( jsonml[0] === "__RAW" ) { + return jsonml[1]; + } + + var tag = jsonml.shift(), + attributes = {}, + content = []; + + if ( jsonml.length && typeof jsonml[ 0 ] === "object" && !( jsonml[ 0 ] instanceof Array ) ) + attributes = jsonml.shift(); + + while ( jsonml.length ) + content.push( render_tree( jsonml.shift() ) ); + + var tag_attrs = ""; + if (typeof attributes.src !== 'undefined') { + tag_attrs += ' src="' + escapeHTML( attributes.src ) + '"'; + delete attributes.src; + } + + for ( var a in attributes ) { + var escaped = escapeHTML( attributes[ a ]); + if (escaped && escaped.length) { + tag_attrs += " " + a + '="' + escaped + '"'; + } + } + + // be careful about adding whitespace here for inline elements + if ( tag === "img" || tag === "br" || tag === "hr" ) + return "<"+ tag + tag_attrs + "/>"; + else + return "<"+ tag + tag_attrs + ">" + content.join( "" ) + ""; + } + + function convert_tree_to_html( tree, references, options ) { + var i; + options = options || {}; + + // shallow clone + var jsonml = tree.slice( 0 ); + + if ( typeof options.preprocessTreeNode === "function" ) + jsonml = options.preprocessTreeNode(jsonml, references); + + // Clone attributes if they exist + var attrs = extract_attr( jsonml ); + if ( attrs ) { + jsonml[ 1 ] = {}; + for ( i in attrs ) { + jsonml[ 1 ][ i ] = attrs[ i ]; + } + attrs = jsonml[ 1 ]; + } + + // basic case + if ( typeof jsonml === "string" ) + return jsonml; + + // convert this node + switch ( jsonml[ 0 ] ) { + case "header": + jsonml[ 0 ] = "h" + jsonml[ 1 ].level; + delete jsonml[ 1 ].level; + break; + case "bulletlist": + jsonml[ 0 ] = "ul"; + break; + case "numberlist": + jsonml[ 0 ] = "ol"; + break; + case "listitem": + jsonml[ 0 ] = "li"; + break; + case "para": + jsonml[ 0 ] = "p"; + break; + case "markdown": + jsonml[ 0 ] = "html"; + if ( attrs ) + delete attrs.references; + break; + case "code_block": + jsonml[ 0 ] = "pre"; + i = attrs ? 2 : 1; + var code = [ "code" ]; + code.push.apply( code, jsonml.splice( i, jsonml.length - i ) ); + jsonml[ i ] = code; + break; + case "inlinecode": + jsonml[ 0 ] = "code"; + break; + case "img": + jsonml[ 1 ].src = jsonml[ 1 ].href; + delete jsonml[ 1 ].href; + break; + case "linebreak": + jsonml[ 0 ] = "br"; + break; + case "link": + jsonml[ 0 ] = "a"; + break; + case "link_ref": + jsonml[ 0 ] = "a"; + + // grab this ref and clean up the attribute node + var ref = references[ attrs.ref ]; + + // if the reference exists, make the link + if ( ref ) { + delete attrs.ref; + + // add in the href and title, if present + attrs.href = ref.href; + if ( ref.title ) + attrs.title = ref.title; + + // get rid of the unneeded original text + delete attrs.original; + } + // the reference doesn't exist, so revert to plain text + else { + return attrs.original; + } + break; + case "img_ref": + jsonml[ 0 ] = "img"; + + // grab this ref and clean up the attribute node + var ref = references[ attrs.ref ]; + + // if the reference exists, make the link + if ( ref ) { + delete attrs.ref; + + // add in the href and title, if present + attrs.src = ref.href; + if ( ref.title ) + attrs.title = ref.title; + + // get rid of the unneeded original text + delete attrs.original; + } + // the reference doesn't exist, so revert to plain text + else { + return attrs.original; + } + break; + } + + // convert all the children + i = 1; + + // deal with the attribute node, if it exists + if ( attrs ) { + // if there are keys, skip over it + for ( var key in jsonml[ 1 ] ) { + i = 2; + break; + } + // if there aren't, remove it + if ( i === 1 ) + jsonml.splice( i, 1 ); + } + + for ( ; i < jsonml.length; ++i ) { + jsonml[ i ] = convert_tree_to_html( jsonml[ i ], references, options ); + } + + return jsonml; + } + + // merges adjacent text nodes into a single node + function merge_text_nodes( jsonml ) { + // skip the tag name and attribute hash + var i = extract_attr( jsonml ) ? 2 : 1; + + while ( i < jsonml.length ) { + // if it's a string check the next item too + if ( typeof jsonml[ i ] === "string" ) { + if ( i + 1 < jsonml.length && typeof jsonml[ i + 1 ] === "string" ) { + // merge the second string into the first and remove it + jsonml[ i ] += jsonml.splice( i + 1, 1 )[ 0 ]; + } + else { + ++i; + } + } + // if it's not a string recurse + else { + merge_text_nodes( jsonml[ i ] ); + ++i; + } + } + } + + var DialectHelpers = {}; + DialectHelpers.inline_until_char = function( text, want ) { + var consumed = 0, + nodes = [], + patterns = this.dialect.inline.__patterns__.replace('|_|', '|'); + + while ( true ) { + if ( text.charAt( consumed ) === want ) { + // Found the character we were looking for + consumed++; + return [ consumed, nodes ]; + } + + if ( consumed >= text.length ) { + // No closing char found. Abort. + return [consumed, null, nodes]; + } + + var res = this.dialect.inline.__oneElement__.call(this, text.substr( consumed ), patterns, [text.substr(0, consumed)]); + consumed += res[ 0 ]; + // Add any returned nodes. + nodes.push.apply( nodes, res.slice( 1 ) ); + } + }; + + // Helper function to make sub-classing a dialect easier + DialectHelpers.subclassDialect = function( d ) { + function Block() {} + Block.prototype = d.block; + function Inline() {} + Inline.prototype = d.inline; + + return { block: new Block(), inline: new Inline() }; + }; + + var forEach = MarkdownHelpers.forEach, + extract_attr = MarkdownHelpers.extract_attr, + mk_block = MarkdownHelpers.mk_block, + isEmpty = MarkdownHelpers.isEmpty, + inline_until_char = DialectHelpers.inline_until_char; + + // A robust regexp for matching URLs. Thakns: https://gist.github.com/dperini/729294 + var urlRegexp = /(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-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,})))(?::\d{2,5})?(?:\/[^\s]*)?/i.source; + + /** + * Gruber dialect + * + * The default dialect that follows the rules set out by John Gruber's + * markdown.pl as closely as possible. Well actually we follow the behaviour of + * that script which in some places is not exactly what the syntax web page + * says. + **/ + var Gruber = { + block: { + atxHeader: function atxHeader( block, next ) { + var m = block.match( /^(#{1,6})\s*(.*?)\s*#*\s*(?:\n|$)/ ); + + if ( !m ) + return undefined; + + var header = [ "header", { level: m[ 1 ].length } ]; + Array.prototype.push.apply(header, this.processInline(m[ 2 ])); + + if ( m[0].length < block.length ) + next.unshift( mk_block( block.substr( m[0].length ), block.trailing, block.lineNumber + 2 ) ); + + return [ header ]; + }, + + setextHeader: function setextHeader( block, next ) { + var m = block.match( /^(.*)\n([-=])\2\2+(?:\n|$)/ ); + + if ( !m ) + return undefined; + + var level = ( m[ 2 ] === "=" ) ? 1 : 2, + header = [ "header", { level : level } ].concat( this.processInline(m[ 1 ]) ); + + if ( m[0].length < block.length ) + next.unshift( mk_block( block.substr( m[0].length ), block.trailing, block.lineNumber + 2 ) ); + + return [ header ]; + }, + + code: function code( block, next ) { + // | Foo + // |bar + // should be a code block followed by a paragraph. Fun + // + // There might also be adjacent code block to merge. + + var ret = [], + re = /^(?: {0,3}\t| {4})(.*)\n?/; + + // 4 spaces + content + if ( !block.match( re ) ) + return undefined; + + block_search: + do { + // Now pull out the rest of the lines + var b = this.loop_re_over_block( + re, block.valueOf(), function( m ) { ret.push( m[1] ); } ); + + if ( b.length ) { + // Case alluded to in first comment. push it back on as a new block + next.unshift( mk_block(b, block.trailing) ); + break block_search; + } + else if ( next.length ) { + // Check the next block - it might be code too + if ( !next[0].match( re ) ) + break block_search; + + // Pull how how many blanks lines follow - minus two to account for .join + ret.push ( block.trailing.replace(/[^\n]/g, "").substring(2) ); + + block = next.shift(); + } + else { + break block_search; + } + } while ( true ); + + return [ [ "code_block", ret.join("\n") ] ]; + }, + + horizRule: function horizRule( block, next ) { + // this needs to find any hr in the block to handle abutting blocks + var m = block.match( /^(?:([\s\S]*?)\n)?[ \t]*([-_*])(?:[ \t]*\2){2,}[ \t]*(?:\n([\s\S]*))?$/ ); + + if ( !m ) + return undefined; + + var jsonml = [ [ "hr" ] ]; + + // if there's a leading abutting block, process it + if ( m[ 1 ] ) { + var contained = mk_block( m[ 1 ], "", block.lineNumber ); + jsonml.unshift.apply( jsonml, this.toTree( contained, [] ) ); + } + + // if there's a trailing abutting block, stick it into next + if ( m[ 3 ] ) + next.unshift( mk_block( m[ 3 ], block.trailing, block.lineNumber + 1 ) ); + + return jsonml; + }, + + // There are two types of lists. Tight and loose. Tight lists have no whitespace + // between the items (and result in text just in the
  • ) and loose lists, + // which have an empty line between list items, resulting in (one or more) + // paragraphs inside the
  • . + // + // There are all sorts weird edge cases about the original markdown.pl's + // handling of lists: + // + // * Nested lists are supposed to be indented by four chars per level. But + // if they aren't, you can get a nested list by indenting by less than + // four so long as the indent doesn't match an indent of an existing list + // item in the 'nest stack'. + // + // * The type of the list (bullet or number) is controlled just by the + // first item at the indent. Subsequent changes are ignored unless they + // are for nested lists + // + lists: (function( ) { + // Use a closure to hide a few variables. + var any_list = "[*+-]|\\d+\\.", + bullet_list = /[*+-]/, + // Capture leading indent as it matters for determining nested lists. + is_list_re = new RegExp( "^( {0,3})(" + any_list + ")[ \t]+" ), + indent_re = "(?: {0,3}\\t| {4})"; + + // TODO: Cache this regexp for certain depths. + // Create a regexp suitable for matching an li for a given stack depth + function regex_for_depth( depth ) { + return new RegExp( + // m[1] = indent, m[2] = list_type + "(?:^(" + indent_re + "{0," + depth + "} {0,3})(" + any_list + ")\\s+)|" + + // m[3] = cont + "(^" + indent_re + "{0," + (depth-1) + "}[ ]{0,4})" + ); + } + function expand_tab( input ) { + return input.replace( / {0,3}\t/g, " " ); + } + + // Add inline content `inline` to `li`. inline comes from processInline + // so is an array of content + function add(li, loose, inline, nl) { + if ( loose ) { + li.push( [ "para" ].concat(inline) ); + return; + } + // Hmmm, should this be any block level element or just paras? + var add_to = li[li.length -1] instanceof Array && li[li.length - 1][0] === "para" + ? li[li.length -1] + : li; + + // If there is already some content in this list, add the new line in + if ( nl && li.length > 1 ) + inline.unshift(nl); + + for ( var i = 0; i < inline.length; i++ ) { + var what = inline[i], + is_str = typeof what === "string"; + if ( is_str && add_to.length > 1 && typeof add_to[add_to.length-1] === "string" ) + add_to[ add_to.length-1 ] += what; + else + add_to.push( what ); + } + } + + // contained means have an indent greater than the current one. On + // *every* line in the block + function get_contained_blocks( depth, blocks ) { + + var re = new RegExp( "^(" + indent_re + "{" + depth + "}.*?\\n?)*$" ), + replace = new RegExp("^" + indent_re + "{" + depth + "}", "gm"), + ret = []; + + + while ( blocks.length > 0 ) { + // HACK: Fixes a v8 issue + test = blocks[0].replace(/^ {8,}/, ' '); + if ( re.exec( test ) ) { + var b = blocks.shift(), + // Now remove that indent + x = b.replace( replace, ""); + + ret.push( mk_block( x, b.trailing, b.lineNumber ) ); + } + else + break; + } + return ret; + } + + // passed to stack.forEach to turn list items up the stack into paras + function paragraphify(s, i, stack) { + var list = s.list; + var last_li = list[list.length-1]; + + if ( last_li[1] instanceof Array && last_li[1][0] === "para" ) + return; + + if ( i + 1 === stack.length ) { + // Last stack frame + // Keep the same array, but replace the contents + last_li.push( ["para"].concat( last_li.splice(1, last_li.length - 1) ) ); + } + else { + var sublist = last_li.pop(); + last_li.push( ["para"].concat( last_li.splice(1, last_li.length - 1) ), sublist ); + } + } + + // The matcher function + return function( block, next ) { + var m = block.match( is_list_re ); + if ( !m ) + return undefined; + + function make_list( m ) { + var list = bullet_list.exec( m[2] ) + ? ["bulletlist"] + : ["numberlist"]; + + stack.push( { list: list, indent: m[1] } ); + return list; + } + + var stack = [], // Stack of lists for nesting. + list = make_list( m ), + last_li, + loose = false, + ret = [ stack[0].list ], + i; + + // Loop to search over block looking for inner block elements and loose lists + loose_search: + while ( true ) { + // Split into lines preserving new lines at end of line + var lines = block.split( /(?=\n)/ ); + + // We have to grab all lines for a li and call processInline on them + // once as there are some inline things that can span lines. + var li_accumulate = "", nl = ""; + + // Loop over the lines in this block looking for tight lists. + tight_search: + for ( var line_no = 0; line_no < lines.length; line_no++ ) { + nl = ""; + var l = lines[line_no].replace(/^\n/, function(n) { nl = n; return ""; }); + + // TODO: really should cache this + var line_re = regex_for_depth( stack.length ); + + m = l.match( line_re ); + //print( "line:", uneval(l), "\nline match:", uneval(m) ); + + // We have a list item + if ( m[1] !== undefined ) { + // Process the previous list item, if any + if ( li_accumulate.length ) { + add( last_li, loose, this.processInline( li_accumulate ), nl ); + // Loose mode will have been dealt with. Reset it + loose = false; + li_accumulate = ""; + } + + m[1] = expand_tab( m[1] ); + var wanted_depth = Math.floor(m[1].length/4)+1; + //print( "want:", wanted_depth, "stack:", stack.length); + if ( wanted_depth > stack.length ) { + // Deep enough for a nested list outright + //print ( "new nested list" ); + list = make_list( m ); + last_li.push( list ); + last_li = list[1] = [ "listitem" ]; + } + else { + // We aren't deep enough to be strictly a new level. This is + // where Md.pl goes nuts. If the indent matches a level in the + // stack, put it there, else put it one deeper then the + // wanted_depth deserves. + var found = false; + for ( i = 0; i < stack.length; i++ ) { + if ( stack[ i ].indent !== m[1] ) + continue; + + list = stack[ i ].list; + stack.splice( i+1, stack.length - (i+1) ); + found = true; + break; + } + + if (!found) { + //print("not found. l:", uneval(l)); + wanted_depth++; + if ( wanted_depth <= stack.length ) { + stack.splice(wanted_depth, stack.length - wanted_depth); + //print("Desired depth now", wanted_depth, "stack:", stack.length); + list = stack[wanted_depth-1].list; + //print("list:", uneval(list) ); + } + else { + //print ("made new stack for messy indent"); + list = make_list(m); + last_li.push(list); + } + } + + //print( uneval(list), "last", list === stack[stack.length-1].list ); + last_li = [ "listitem" ]; + list.push(last_li); + } // end depth of shenegains + nl = ""; + } + + // Add content + if ( l.length > m[0].length ) + li_accumulate += nl + l.substr( m[0].length ); + } // tight_search + + if ( li_accumulate.length ) { + + var contents = this.processBlock(li_accumulate, []), + firstBlock = contents[0]; + + if (firstBlock) { + firstBlock.shift(); + contents.splice.apply(contents, [0, 1].concat(firstBlock)); + add( last_li, loose, contents, nl ); + + // Let's not creating a trailing \n after content in the li + if(last_li[last_li.length-1] === "\n") { + last_li.pop(); + } + + // Loose mode will have been dealt with. Reset it + loose = false; + li_accumulate = ""; + } + } + + // Look at the next block - we might have a loose list. Or an extra + // paragraph for the current li + var contained = get_contained_blocks( stack.length, next ); + + // Deal with code blocks or properly nested lists + if ( contained.length > 0 ) { + // Make sure all listitems up the stack are paragraphs + forEach( stack, paragraphify, this); + + last_li.push.apply( last_li, this.toTree( contained, [] ) ); + } + + var next_block = next[0] && next[0].valueOf() || ""; + + if ( next_block.match(is_list_re) ) { + block = next.shift(); + + // Check for an HR following a list: features/lists/hr_abutting + var hr = this.dialect.block.horizRule.call( this, block, next ); + + if ( hr ) { + ret.push.apply(ret, hr); + break; + } + + // Add paragraphs if the indentation level stays the same + if (stack[stack.length-1].indent === block.match(/^\s*/)[0]) { + forEach( stack, paragraphify, this); + } + + loose = true; + continue loose_search; + } + break; + } // loose_search + + return ret; + }; + })(), + + blockquote: function blockquote( block, next ) { + + // Handle quotes that have spaces before them + var m = /(^|\n) +(\>[\s\S]*)/.exec(block); + if (m && m[2] && m[2].length) { + var blockContents = block.replace(/(^|\n) +\>/, "$1>"); + next.unshift(blockContents); + return []; + } + + if ( !block.match( /^>/m ) ) + return undefined; + + var jsonml = []; + + // separate out the leading abutting block, if any. I.e. in this case: + // + // a + // > b + // + if ( block[ 0 ] !== ">" ) { + var lines = block.split( /\n/ ), + prev = [], + line_no = block.lineNumber; + + // keep shifting lines until you find a crotchet + while ( lines.length && lines[ 0 ][ 0 ] !== ">" ) { + prev.push( lines.shift() ); + line_no++; + } + + var abutting = mk_block( prev.join( "\n" ), "\n", block.lineNumber ); + jsonml.push.apply( jsonml, this.processBlock( abutting, [] ) ); + // reassemble new block of just block quotes! + block = mk_block( lines.join( "\n" ), block.trailing, line_no ); + } + + // if the next block is also a blockquote merge it in + while ( next.length && next[ 0 ][ 0 ] === ">" ) { + var b = next.shift(); + block = mk_block( block + block.trailing + b, b.trailing, block.lineNumber ); + } + + // Strip off the leading "> " and re-process as a block. + var input = block.replace( /^> ?/gm, "" ), + old_tree = this.tree, + processedBlock = this.toTree( input, [ "blockquote" ] ), + attr = extract_attr( processedBlock ); + + // If any link references were found get rid of them + if ( attr && attr.references ) { + delete attr.references; + // And then remove the attribute object if it's empty + if ( isEmpty( attr ) ) + processedBlock.splice( 1, 1 ); + } + + jsonml.push( processedBlock ); + return jsonml; + }, + + referenceDefn: function referenceDefn( block, next) { + var re = /^\s*\[([^\[\]]+)\]:\s*(\S+)(?:\s+(?:(['"])(.*)\3|\((.*?)\)))?\n?/; + // interesting matches are [ , ref_id, url, , title, title ] + + if ( !block.match(re) ) + return undefined; + + var attrs = create_attrs.call( this ); + + var b = this.loop_re_over_block(re, block, function( m ) { + create_reference(attrs, m); + } ); + + if ( b.length ) + next.unshift( mk_block( b, block.trailing ) ); + + return []; + }, + + para: function para( block ) { + // everything's a para! + return [ ["para"].concat( this.processInline( block ) ) ]; + } + }, + + inline: { + + __oneElement__: function oneElement( text, patterns_or_re, previous_nodes ) { + + // PERF NOTE: rewritten to avoid greedy match regex \([\s\S]*?)(...)\ + // greedy match performs horribly with large inline blocks, it can be so + // slow it will crash chrome + patterns_or_re = patterns_or_re || this.dialect.inline.__patterns__; + + var search_re = new RegExp(patterns_or_re.source || patterns_or_re); + var pos = text.search(search_re); + + if (pos === -1) { + return [ text.length, text ]; + } else if (pos !== 0) { + // Some un-interesting text matched. Return that first + return [pos, text.substring(0,pos)]; + } + + var match_re = new RegExp( "^(" + (patterns_or_re.source || patterns_or_re) + ")" ); + var m = match_re.exec( text ); + var res; + if ( m[1] in this.dialect.inline ) { + res = this.dialect.inline[ m[1] ].call( + this, + text.substr( m.index ), m, previous_nodes || [] ); + + // If no inline code executed, fallback + if (!res) { + var fn = this.dialect.inline[m[1][0]]; + if (fn) { + res = fn.call( + this, + text.substr( m.index ), m, previous_nodes || [] ); + } + } + } + // Default for now to make dev easier. just slurp special and output it. + res = res || [ m[1].length, m[1] ]; + return res; + }, + + __call__: function inline( text, patterns ) { + + var out = [], + res; + + function add(x) { + //D:self.debug(" adding output", uneval(x)); + if ( typeof x === "string" && typeof out[out.length-1] === "string" ) + out[ out.length-1 ] += x; + else + out.push(x); + } + + while ( text.length > 0 ) { + res = this.dialect.inline.__oneElement__.call(this, text, patterns, out ); + text = text.substr( res.shift() ); + forEach(res, add ); + } + + return out; + }, + + // These characters are interesting elsewhere, so have rules for them so that + // chunks of plain text blocks don't include them + "]": function () {}, + "}": function () {}, + + __escape__ : /^\\[\\`\*_{}<>\[\]()#\+.!\-]/, + + "\\": function escaped( text ) { + // [ length of input processed, node/children to add... ] + // Only esacape: \ ` * _ { } [ ] ( ) # * + - . ! + if ( this.dialect.inline.__escape__.exec( text ) ) + return [ 2, text.charAt( 1 ) ]; + else + // Not an esacpe + return [ 1, "\\" ]; + }, + + "![": function image( text ) { + + // Without this guard V8 crashes hard on the RegExp + if (text.indexOf('(') >= 0 && text.indexOf(')') === -1) { return; } + + // Unlike images, alt text is plain text only. no other elements are + // allowed in there + + // ![Alt text](/path/to/img.jpg "Optional title") + // 1 2 3 4 <--- captures + // + // First attempt to use a strong URL regexp to catch things like parentheses. If it misses, use the + // old one. + var origMatcher = /^!\[(.*?)\][ \t]*\([ \t]*([^")]*?)(?:[ \t]+(["'])(.*?)\3)?[ \t]*\)/; + m = text.match(new RegExp("^!\\[(.*?)][ \\t]*\\((" + urlRegexp + ")\\)([ \\t])*([\"'].*[\"'])?")) || + text.match(origMatcher); + + if (m && m[2].indexOf(")]") !== -1) { m = text.match(origMatcher); } + + if ( m ) { + if ( m[2] && m[2][0] === "<" && m[2][m[2].length-1] === ">" ) + m[2] = m[2].substring( 1, m[2].length - 1 ); + + m[2] = this.dialect.inline.__call__.call( this, m[2], /\\/ )[0]; + + var attrs = { alt: m[1], href: m[2] || "" }; + if ( m[4] !== undefined) + attrs.title = m[4]; + + return [ m[0].length, [ "img", attrs ] ]; + } + + // ![Alt text][id] + m = text.match( /^!\[(.*?)\][ \t]*\[(.*?)\]/ ); + + if ( m ) { + // We can't check if the reference is known here as it likely wont be + // found till after. Check it in md tree->hmtl tree conversion + return [ m[0].length, [ "img_ref", { alt: m[1], ref: m[2].toLowerCase(), original: m[0] } ] ]; + } + + // Just consume the '![' + return [ 2, "![" ]; + }, + + "[": function link( text ) { + + var open = 1; + for (var i=0; i 3) { return [1, "["]; } + } + + var orig = String(text); + // Inline content is possible inside `link text` + var res = inline_until_char.call( this, text.substr(1), "]" ); + + // No closing ']' found. Just consume the [ + if ( !res[1] ) { + return [ res[0] + 1, text.charAt(0) ].concat(res[2]); + } + + if ( res[0] == 1 ) { return [ 2, "[]" ]; } // empty link found. + + var consumed = 1 + res[ 0 ], + children = res[ 1 ], + link, + attrs; + + // At this point the first [...] has been parsed. See what follows to find + // out which kind of link we are (reference or direct url) + text = text.substr( consumed ); + + // [link text](/path/to/img.jpg "Optional title") + // 1 2 3 <--- captures + // This will capture up to the last paren in the block. We then pull + // back based on if there a matching ones in the url + // ([here](/url/(test)) + // The parens have to be balanced + var m = text.match( /^\s*\([ \t]*([^"'\s]*)(?:[ \t]+(["'])(.*?)\2)?[ \t]*\)/ ); + if ( m ) { + var url = m[1].replace(/\s+$/, ''); + consumed += m[0].length; + + if ( url && url[0] === "<" && url[url.length-1] === ">" ) + url = url.substring( 1, url.length - 1 ); + + // If there is a title we don't have to worry about parens in the url + if ( !m[3] ) { + var open_parens = 1; // One open that isn't in the capture + for ( var len = 0; len < url.length; len++ ) { + switch ( url[len] ) { + case "(": + open_parens++; + break; + case ")": + if ( --open_parens === 0) { + consumed -= url.length - len; + url = url.substring(0, len); + } + break; + } + } + } + + // Process escapes only + url = this.dialect.inline.__call__.call( this, url, /\\/ )[0]; + + attrs = { href: url || "" }; + if ( m[3] !== undefined) + attrs.title = m[3]; + + link = [ "link", attrs ].concat( children ); + return [ consumed, link ]; + } + + if (text.indexOf('(') === 0 && text.indexOf(')') !== -1) { + m = text.match(new RegExp("^\\((" + urlRegexp + ")\\)")); + if (m && m[1]) { + consumed += m[0].length; + link = ["link", {href: m[1]}].concat(children); + return [consumed, link]; + } + } + + // [Alt text][id] + // [Alt text] [id] + m = text.match( /^\s*\[(.*?)\]/ ); + if ( m ) { + + consumed += m[ 0 ].length; + + // [links][] uses links as its reference + attrs = { ref: ( m[ 1 ] || String(children) ).toLowerCase(), original: orig.substr( 0, consumed ) }; + + if (children && children.length > 0) { + link = [ "link_ref", attrs ].concat( children ); + + // We can't check if the reference is known here as it likely wont be + // found till after. Check it in md tree->hmtl tree conversion. + // Store the original so that conversion can revert if the ref isn't found. + return [ consumed, link ]; + } + } + + // Another check for references + m = orig.match(/^\s*\[(.*?)\]:\s*(\S+)(?:\s+(?:(['"])(.*?)\3|\((.*?)\)))?\n?/); + if (m && + (/^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-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,})))(?::\d{2,5})?(?:\/[^\s]*)?$/i.test(m[2]) || + /(\/[\w~,;\-\./?%&+#=]*)/.test(m[2]))) { + attrs = create_attrs.call(this); + create_reference(attrs, m); + + return [ m[0].length ]; + } + + // [id] + // Only if id is plain (no formatting.) + if ( children.length === 1 && typeof children[0] === "string" ) { + + var normalized = children[0].toLowerCase().replace(/\s+/, ' '); + attrs = { ref: normalized, original: orig.substr( 0, consumed ) }; + link = [ "link_ref", attrs, children[0] ]; + return [ consumed, link ]; + } + + // Just consume the "[" + return [ 1, "[" ]; + }, + + "<": function autoLink( text ) { + var m; + + if ( ( m = text.match( /^<(?:((https?|ftp|mailto):[^>]+)|(.*?@.*?\.[a-zA-Z]+))>/ ) ) !== null ) { + if ( m[3] ) + return [ m[0].length, [ "link", { href: "mailto:" + m[3] }, m[3] ] ]; + else if ( m[2] === "mailto" ) + return [ m[0].length, [ "link", { href: m[1] }, m[1].substr("mailto:".length ) ] ]; + else + return [ m[0].length, [ "link", { href: m[1] }, m[1] ] ]; + } + + return [ 1, "<" ]; + }, + + "`": function inlineCode( text, match, prev ) { + + // If we're in a tag, don't do it. + if (prev && (typeof prev[0] === "string") && prev[0].match(/<[^>]+$/)) { return; } + + // Inline code block. as many backticks as you like to start it + // Always skip over the opening ticks. + var m = text.match( /(`+)(([\s\S]*?)\1)/ ); + + if ( m && m[2] ) + return [ m[1].length + m[2].length, [ "inlinecode", m[3] ] ]; + else { + // TODO: No matching end code found - warn! + return [ 1, "`" ]; + } + }, + + " \n": function lineBreak() { + return [ 3, [ "linebreak" ] ]; + } + + } + }; + + // A helper function to create attributes + function create_attrs() { + if ( !extract_attr( this.tree ) ) { + this.tree.splice( 1, 0, {} ); + } + + var attrs = extract_attr( this.tree ); + + // make a references hash if it doesn't exist + if ( attrs.references === undefined ) { + attrs.references = {}; + } + + return attrs; + } + + // Create references for attributes + function create_reference(attrs, m) { + if ( m[2] && m[2][0] === "<" && m[2][m[2].length-1] === ">" ) + m[2] = m[2].substring( 1, m[2].length - 1 ); + + var ref = attrs.references[ m[1].toLowerCase() ] = { + href: m[2] + }; + + if ( m[4] !== undefined ) + ref.title = m[4]; + else if ( m[5] !== undefined ) + ref.title = m[5]; + } + + Markdown.dialects.Gruber = Gruber; + Markdown.buildBlockOrder ( Markdown.dialects.Gruber.block ); + Markdown.buildInlinePatterns( Markdown.dialects.Gruber.inline ); + +// Include all our dependencies and return the resulting library. + + expose.Markdown = Markdown; + expose.parse = Markdown.parse; + expose.toHTML = Markdown.toHTML; + expose.toHTMLTree = Markdown.toHTMLTree; + expose.renderJsonML = Markdown.renderJsonML; + expose.DialectHelpers = DialectHelpers; + +})(function() { + window.BetterMarkdown = {}; + return window.BetterMarkdown; +}());