diff --git a/test/api/unit/libs/highlightMentions.js b/test/api/unit/libs/highlightMentions.test.js similarity index 51% rename from test/api/unit/libs/highlightMentions.js rename to test/api/unit/libs/highlightMentions.test.js index 6d46e14aee..a68cd9d6d7 100644 --- a/test/api/unit/libs/highlightMentions.js +++ b/test/api/unit/libs/highlightMentions.test.js @@ -1,7 +1,5 @@ import mongoose from 'mongoose'; -import { - highlightMentions, -} from '../../../../website/server/libs/highlightMentions'; +import highlightMentions from '../../../../website/server/libs/highlightMentions'; describe('highlightMentions', () => { beforeEach(() => { @@ -13,9 +11,12 @@ describe('highlightMentions', () => { return this; }, exec () { - return Promise.resolve([{ - auth: { local: { username: 'user' } }, _id: '111', - }, { auth: { local: { username: 'user2' } }, _id: '222' }, { auth: { local: { username: 'user3' } }, _id: '333' }, { auth: { local: { username: 'user-dash' } }, _id: '444' }, { auth: { local: { username: 'user_underscore' } }, _id: '555' }, + return Promise.resolve([ + { auth: { local: { username: 'user' } }, _id: '111' }, + { auth: { local: { username: 'user2' } }, _id: '222' }, + { auth: { local: { username: 'user3' } }, _id: '333' }, + { auth: { local: { username: 'user-dash' } }, _id: '444' }, + { auth: { local: { username: 'user_underscore' } }, _id: '555' }, ]); }, }; @@ -32,29 +33,76 @@ describe('highlightMentions', () => { const result = await highlightMentions(text); expect(result[0]).to.equal(text); }); + it('highlights existing users', async () => { const text = '@user: message'; const result = await highlightMentions(text); expect(result[0]).to.equal('[@user](/profile/111): message'); }); + it('highlights special characters', async () => { const text = '@user-dash: message @user_underscore'; const result = await highlightMentions(text); expect(result[0]).to.equal('[@user-dash](/profile/444): message [@user_underscore](/profile/555)'); }); + it('doesn\'t highlight nonexisting users', async () => { const text = '@nouser message'; const result = await highlightMentions(text); expect(result[0]).to.equal('@nouser message'); }); + it('highlights multiple existing users', async () => { const text = '@user message (@user2) @user3 @user'; const result = await highlightMentions(text); expect(result[0]).to.equal('[@user](/profile/111) message ([@user2](/profile/222)) [@user3](/profile/333) [@user](/profile/111)'); }); + it('doesn\'t highlight more than 5 users', async () => { const text = '@user @user2 @user3 @user4 @user5 @user6'; const result = await highlightMentions(text); expect(result[0]).to.equal(text); }); + + describe('exceptions in code blocks', () => { + it('doesn\'t highlight user in inline code block', async () => { + const text = '`@user`'; + + const result = await highlightMentions(text); + + expect(result[0]).to.equal(text); + }); + + it('doesn\'t highlight user in fenced code block', async () => { + const text = 'Text\n\n```\n// code referencing @user\n```\n\nText'; + + const result = await highlightMentions(text); + + expect(result[0]).to.equal(text); + }); + + it('doesn\'t highlight user in indented code block', async () => { + const text = ' @user'; + + const result = await highlightMentions(text); + + expect(result[0]).to.equal(text); + }); + + it('does highlight user that\'s after in-line code block', async () => { + const text = '`` for @user'; + + const result = await highlightMentions(text); + + expect(result[0]).to.equal('`` for [@user](/profile/111)'); + }); + + it('does highlight same content properly', async () => { + const text = '@user `@user`'; + + const result = await highlightMentions(text); + + expect(result[0]).to.equal('[@user](/profile/111) `@user`'); + }); + }); }); diff --git a/website/server/controllers/api-v3/chat.js b/website/server/controllers/api-v3/chat.js index 60e5284594..5ade8f037a 100644 --- a/website/server/controllers/api-v3/chat.js +++ b/website/server/controllers/api-v3/chat.js @@ -22,7 +22,7 @@ import guildsAllowingBannedWords from '../../libs/guildsAllowingBannedWords'; import { getMatchesByWordArray } from '../../libs/stringUtils'; import bannedSlurs from '../../libs/bannedSlurs'; import apiError from '../../libs/apiError'; -import { highlightMentions } from '../../libs/highlightMentions'; +import highlightMentions from '../../libs/highlightMentions'; const FLAG_REPORT_EMAILS = nconf.get('FLAG_REPORT_EMAIL').split(',').map(email => ({ email, canSend: true })); diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index bc2e5b3065..01334d26b4 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -24,7 +24,7 @@ import { sentMessage } from '../../libs/inbox'; import { sanitizeText as sanitizeMessageText, } from '../../models/message'; -import { highlightMentions } from '../../libs/highlightMentions'; +import highlightMentions from '../../libs/highlightMentions'; const { achievements } = common; diff --git a/website/server/libs/highlightMentions.js b/website/server/libs/highlightMentions.js index b04777ca2d..a50c490593 100644 --- a/website/server/libs/highlightMentions.js +++ b/website/server/libs/highlightMentions.js @@ -1,12 +1,117 @@ +import habiticaMarkdown from 'habitica-markdown'; + import { model as User } from '../models/user'; -const mentionRegex = new RegExp('\\B@[-\\w]+', 'g'); +const mentionRegex = /\B@[-\w]+/g; +const codeTokenTypes = ['code_block', 'code_inline', 'fence']; -export async function highlightMentions (text) { // eslint-disable-line import/prefer-default-export - const mentions = text.match(mentionRegex); +/** + * Container class for text blocks and code blocks combined + * Blocks have the properties `text` and `isCodeBlock` + */ +class TextWithCodeBlocks { + constructor (blocks) { + this.blocks = blocks; + this.textBlocks = blocks.filter(block => !block.isCodeBlock); + this.allText = this.textBlocks.map(block => block.text).join('\n'); + } + + transformTextBlocks (transform) { + this.textBlocks.forEach(block => { + block.text = transform(block.text); + }); + } + + rebuild () { + return this.blocks.map(block => block.text).join(''); + } +} + +/** + * Since tokens have both order and can be nested until infinite depth, + * use a branching recursive algorithm to maintain order and check all tokens. + */ +function findCodeBlocks (tokens, aggregator) { + const result = aggregator || []; + const [head, ...tail] = tokens; + if (!head) { + return result; + } + + if (codeTokenTypes.includes(head.type)) { + result.push(head); + } + + return findCodeBlocks(tail, head.children ? findCodeBlocks(head.children, result) : result); +} + +/** + * Since there are many factors that can prefix lines with indentation in + * markdown, each line from a token's content needs to be prefixed with a + * variable whitespace matcher. + * + * See for example: https://spec.commonmark.org/0.29/#example-224 + */ +function withOptionalIndentation (content) { + return content.split('\n').map(line => `\\s*${line}`).join('\n'); +} + +function createCodeBlockRegex ({ content, type, markup }) { + let regexStr = ''; + + if (type === 'code_block') { + regexStr = withOptionalIndentation(content); + } else if (type === 'fence') { + regexStr = `\\s*${markup}.*\n${withOptionalIndentation(content)}\\s*${markup}`; + } else { // type === code_inline + regexStr = `${markup} ?${content} ?${markup}`; + } + + return new RegExp(regexStr); +} + +/** + * Uses habiticaMarkdown to determine what part of the text are code blocks + * according to the specification here: https://spec.commonmark.org/0.29/ + */ +function findTextAndCodeBlocks (text) { + // For token description see https://markdown-it.github.io/markdown-it/#Token + const tokens = habiticaMarkdown.parse(text); + const codeBlocks = findCodeBlocks(tokens); + + const blocks = []; + let remainingText = text; + codeBlocks.forEach(codeBlock => { + const codeBlockRegex = createCodeBlockRegex(codeBlock); + const match = remainingText.match(codeBlockRegex); + + if (match.index) { + blocks.push({ text: remainingText.substr(0, match.index), isCodeBlock: false }); + } + blocks.push({ text: match[0], isCodeBlock: true }); + + remainingText = remainingText.substr(match.index + match[0].length); + }); + + if (remainingText) { + blocks.push({ text: remainingText, isCodeBlock: false }); + } + return new TextWithCodeBlocks(blocks); +} + +/** + * Replaces `@user` mentions by `[@user](/profile/{user-id})` markup to inject + * a link towards the user's profile page. + * - Only works if there are no more that 5 user mentions + * - Skips mentions in code blocks as defined by https://spec.commonmark.org/0.29/ + */ +export default async function highlightMentions (text) { + const textAndCodeBlocks = findTextAndCodeBlocks(text); + + const mentions = textAndCodeBlocks.allText.match(mentionRegex); let members = []; - if (mentions !== null && mentions.length <= 5) { + if (mentions && mentions.length <= 5) { const usernames = mentions.map(mention => mention.substr(1)); members = await User .find({ 'auth.local.username': { $in: usernames }, 'flags.verifiedUsername': true }) @@ -15,9 +120,12 @@ export async function highlightMentions (text) { // eslint-disable-line import/p .exec(); members.forEach(member => { const { username } = member.auth.local; - // eslint-disable-next-line no-param-reassign - text = text.replace(new RegExp(`@${username}(?![\\-\\w])`, 'g'), `[@${username}](/profile/${member._id})`); + const regex = new RegExp(`@${username}(?![\\-\\w])`, 'g'); + const replacement = `[@${username}](/profile/${member._id})`; + + textAndCodeBlocks.transformTextBlocks(blockText => blockText.replace(regex, replacement)); }); } - return [text, mentions, members]; + + return [textAndCodeBlocks.rebuild(), mentions, members]; }