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 index a580087786c..875135bbaf1 100644 --- 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 @@ -1,42 +1,97 @@ -import { inlineRegexRule } from 'pretty-text/engines/markdown-it/helpers'; +import { textReplace } from 'pretty-text/engines/markdown-it/helpers'; -function emitter(matches, state) { - const options = state.md.options.discourse; - const [hashtag, slug] = matches; - const categoryHashtagLookup = options.categoryHashtagLookup; - const result = categoryHashtagLookup && categoryHashtagLookup(slug); +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; + let token; - if (result) { - token = state.push('link_open', 'a', 1); - token.attrs = [['class', 'hashtag'], ['href', result[0]]]; - token.block = false; + if (result) { + token = new state.Token('link_open', 'a', 1); + token.attrs = [['class', 'hashtag'], ['href', result[0]]]; + token.block = false; + buffer.push(token); - token = state.push('text', '', 0); - token.content = '#'; + token = new state.Token('text', '', 0); + token.content = '#'; + buffer.push(token); - token = state.push('span_open', 'span', 1); - token.block = false; + token = new state.Token('span_open', 'span', 1); + token.block = false; + buffer.push(token); - token = state.push('text', '', 0); - token.content = result[1]; + token = new state.Token('text', '', 0); + token.content = result[1]; + buffer.push(token); - state.push('span_close', 'span', -1); + buffer.push(new state.Token('span_close', 'span', -1)); - state.push('link_close', 'a', -1); - } else { + buffer.push(new state.Token('link_close', 'a', -1)); + } else { - token = state.push('span_open', 'span', 1); - token.attrs = [['class', 'hashtag']]; + token = new state.Token('span_open', 'span', 1); + token.attrs = [['class', 'hashtag']]; + buffer.push(token); - token = state.push('text', '', 0); - token.content = hashtag; + token = new state.Token('text', '', 0); + token.content = hashtag; + buffer.push(token); - token = state.push('span_close', 'span', -1); + 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)) { + console.log("not allowed"); + continue; + } } - return true; + // 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) { @@ -45,14 +100,8 @@ export function setup(helper) { helper.registerPlugin(md=>{ - const rule = inlineRegexRule(md, { - start: '#', - matcher: /^#([\w-:]{1,101})/i, - skipInLink: true, - maxLength: 102, - emitter: emitter - }); - - md.inline.ruler.push('category-hashtag', rule); + md.core.ruler.push('category-hashtag', state => textReplace( + state, applyHashtag, true /* skip all links */ + )); }); } diff --git a/app/assets/javascripts/pretty-text/engines/markdown-it/helpers.js.es6 b/app/assets/javascripts/pretty-text/engines/markdown-it/helpers.js.es6 index dbd126fc056..b016b5a86c4 100644 --- a/app/assets/javascripts/pretty-text/engines/markdown-it/helpers.js.es6 +++ b/app/assets/javascripts/pretty-text/engines/markdown-it/helpers.js.es6 @@ -12,6 +12,7 @@ export default null; // matcher: /^#([\w-:]{1,101})/i, // emitter: emitter // }); + export function inlineRegexRule(md, options) { const start = options.start.charCodeAt(0); @@ -27,7 +28,7 @@ export function inlineRegexRule(md, options) { // test prev if (pos > 0) { let prev = state.src.charCodeAt(pos-1); - if (!md.utils.isSpace(prev) && !md.utils.isPunctChar(String.fromCharCode(prev))) { + if (!md.utils.isWhiteSpace(prev) && !md.utils.isPunctChar(String.fromCharCode(prev))) { return false; } } @@ -38,10 +39,10 @@ export function inlineRegexRule(md, options) { for(i=state.tokens.length-1;i>=0;i--) { let token = state.tokens[i]; let type = token.type; - if (type === 'link_open' || (type === 'html_inline' && token.content.substr(0,2) === "")) { + if (type.block || type === 'link_close' || (type === 'html_inline' && token.content.substr(0,4).toLowerCase() === "")) { break; } } @@ -75,10 +76,10 @@ export function inlineRegexRule(md, options) { // based off https://github.com/markdown-it/markdown-it-emoji/blob/master/dist/markdown-it-emoji.js // -export function textReplace(state, callback) { +export function textReplace(state, callback, skipAllLinks) { var i, j, l, tokens, token, blockTokens = state.tokens, - autolinkLevel = 0; + linkLevel = 0; for (j = 0, l = blockTokens.length; j < l; j++) { if (blockTokens[j].type !== 'inline') { continue; } @@ -89,11 +90,23 @@ export function textReplace(state, callback) { for (i = tokens.length - 1; i >= 0; i--) { token = tokens[i]; - if (token.type === 'link_open' || token.type === 'link_close') { - if (token.info === 'auto') { autolinkLevel -= token.nesting; } + if (skipAllLinks) { + if (token.type === 'link_open' || token.type === 'link_close') { + linkLevel -= token.nesting; + } else if (token.type === 'html_inline') { + if (token.content.substr(0,2).toLowerCase() === "") { + linkLevel--; + } + } + } else { + if (token.type === 'link_open' || token.type === 'link_close') { + if (token.info === 'auto') { linkLevel -= token.nesting; } + } } - if (token.type === 'text' && autolinkLevel === 0) { + if (token.type === 'text' && linkLevel === 0) { let split; if(split = callback(token.content, state)) { // replace current node diff --git a/spec/components/pretty_text_spec.rb b/spec/components/pretty_text_spec.rb index cc9d8aaf6ca..966ce95726a 100644 --- a/spec/components/pretty_text_spec.rb +++ b/spec/components/pretty_text_spec.rb @@ -561,6 +561,12 @@ HTML HTML expect(cooked).to eq(html.strip) + + # ensure it does not fight with the autolinker + expect(PrettyText.cook(' http://somewhere.com/#known')).not_to include('hashtag') + expect(PrettyText.cook(' http://somewhere.com/?#known')).not_to include('hashtag') + expect(PrettyText.cook(' http://somewhere.com/?abc#known')).not_to include('hashtag') + end it "can handle mixed lists" do