Files
habitica/website/server/libs/highlightMentions.js

136 lines
4.3 KiB
JavaScript

import escapeRegExp from 'lodash/escapeRegExp';
import habiticaMarkdown from 'habitica-markdown';
import { model as User } from '../models/user';
const mentionRegex = /\B@[-\w]+/g;
const codeTokenTypes = ['code_block', 'code_inline', 'fence'];
/**
* 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 }) {
const contentRegex = escapeRegExp(content);
let regexStr = '';
if (type === 'code_block') {
regexStr = withOptionalIndentation(contentRegex);
} else if (type === 'fence') {
regexStr = `\\s*${markup}.*\n${withOptionalIndentation(contentRegex)}\\s*${markup}`;
} else { // type === code_inline
regexStr = `${markup} ?${contentRegex} ?${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
// The second parameter is mandatory even if not used, see
// https://markdown-it.github.io/markdown-it/#MarkdownIt.parse
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 && mentions.length <= 5) {
const usernames = mentions.map(mention => mention.substr(1));
members = await User
.find({ 'auth.local.username': { $in: usernames }, 'flags.verifiedUsername': true })
.select(['auth.local.username', '_id', 'preferences.pushNotifications', 'pushDevices', 'party', 'guilds'])
.lean()
.exec();
members.forEach(member => {
const { username } = member.auth.local;
const regex = new RegExp(`@${username}(?![\\-\\w])`, 'g');
const replacement = `[@${username}](/profile/${member._id})`;
textAndCodeBlocks.transformTextBlocks(blockText => blockText.replace(regex, replacement));
});
}
return [textAndCodeBlocks.rebuild(), mentions, members];
}