mirror of
https://github.com/discourse/discourse.git
synced 2024-12-13 05:54:29 +08:00
0b3cf83e3c
This commit makes some fundamental changes to how hashtag cooking and icon generation works in the new experimental hashtag autocomplete mode. Previously we cooked the appropriate SVG icon with the cooked hashtag, though this has proved inflexible especially for theming purposes. Instead, we now cook a data-ID attribute with the hashtag and add a new span as an icon placeholder. This is replaced on the client side with an icon (or a square span in the case of categories) on the client side via the decorateCooked API for posts and chat messages. This client side logic uses the generated hashtag, category, and channel CSS classes added in a previous commit. This is missing changes to the sidebar to use the new generated CSS classes and also colors and the split square for categories in the hashtag autocomplete menu -- I will tackle this in a separate PR so it is clearer.
264 lines
7.5 KiB
JavaScript
264 lines
7.5 KiB
JavaScript
import {
|
|
createWatchedWordRegExp,
|
|
toWatchedWord,
|
|
} from "discourse-common/utils/watched-words";
|
|
|
|
const MAX_MATCHES = 100;
|
|
|
|
function isLinkOpen(str) {
|
|
return /^<a[>\s]/i.test(str);
|
|
}
|
|
|
|
function isLinkClose(str) {
|
|
return /^<\/a\s*>/i.test(str);
|
|
}
|
|
|
|
function findAllMatches(text, matchers) {
|
|
const matches = [];
|
|
|
|
let count = 0;
|
|
|
|
matchers.forEach((matcher) => {
|
|
let match;
|
|
while (
|
|
(match = matcher.pattern.exec(text)) !== null &&
|
|
count++ < MAX_MATCHES
|
|
) {
|
|
matches.push({
|
|
index: match.index + match[0].indexOf(match[1]),
|
|
text: match[1],
|
|
replacement: matcher.replacement,
|
|
link: matcher.link,
|
|
});
|
|
}
|
|
});
|
|
|
|
return matches.sort((a, b) => a.index - b.index);
|
|
}
|
|
|
|
// We need this to load after mentions and hashtags which are priority 0
|
|
export const priority = 1;
|
|
|
|
const NONE = 0;
|
|
const MENTION = 1;
|
|
const HASHTAG_LINK = 2;
|
|
const HASHTAG_SPAN = 3;
|
|
const HASHTAG_ICON_SPAN = 4;
|
|
|
|
export function setup(helper) {
|
|
const opts = helper.getOptions();
|
|
|
|
helper.registerPlugin((md) => {
|
|
const matchers = [];
|
|
|
|
if (md.options.discourse.watchedWordsReplace) {
|
|
Object.entries(md.options.discourse.watchedWordsReplace).map(
|
|
([regexpString, options]) => {
|
|
const word = toWatchedWord({ [regexpString]: options });
|
|
|
|
matchers.push({
|
|
pattern: createWatchedWordRegExp(word),
|
|
replacement: options.replacement,
|
|
link: false,
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
if (md.options.discourse.watchedWordsLink) {
|
|
Object.entries(md.options.discourse.watchedWordsLink).map(
|
|
([regexpString, options]) => {
|
|
const word = toWatchedWord({ [regexpString]: options });
|
|
|
|
matchers.push({
|
|
pattern: createWatchedWordRegExp(word),
|
|
replacement: options.replacement,
|
|
link: true,
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
if (matchers.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const cache = new Map();
|
|
|
|
md.core.ruler.push("watched-words", (state) => {
|
|
for (let j = 0, l = state.tokens.length; j < l; j++) {
|
|
if (state.tokens[j].type !== "inline") {
|
|
continue;
|
|
}
|
|
|
|
let tokens = state.tokens[j].children;
|
|
|
|
let htmlLinkLevel = 0;
|
|
|
|
// We scan once to mark tokens that must be skipped because they are
|
|
// mentions or hashtags
|
|
let lastType = NONE;
|
|
let currentType = NONE;
|
|
for (let i = 0; i < tokens.length; ++i) {
|
|
const currentToken = tokens[i];
|
|
|
|
if (currentToken.type === "mention_open") {
|
|
lastType = MENTION;
|
|
} else if (
|
|
(currentToken.type === "link_open" ||
|
|
currentToken.type === "span_open") &&
|
|
currentToken.attrs &&
|
|
currentToken.attrs.some(
|
|
(attr) =>
|
|
attr[0] === "class" &&
|
|
(attr[1] === "hashtag" ||
|
|
attr[1] === "hashtag-cooked" ||
|
|
attr[1] === "hashtag-raw")
|
|
)
|
|
) {
|
|
lastType =
|
|
currentToken.type === "link_open" ? HASHTAG_LINK : HASHTAG_SPAN;
|
|
}
|
|
|
|
if (
|
|
currentToken.type === "span_open" &&
|
|
currentToken.attrs &&
|
|
currentToken.attrs.some(
|
|
(attr) =>
|
|
attr[0] === "class" && attr[1] === "hashtag-icon-placeholder"
|
|
)
|
|
) {
|
|
currentType = HASHTAG_ICON_SPAN;
|
|
}
|
|
|
|
if (lastType !== NONE) {
|
|
currentToken.skipReplace = true;
|
|
}
|
|
|
|
if (
|
|
(lastType === MENTION && currentToken.type === "mention_close") ||
|
|
(lastType === HASHTAG_LINK && currentToken.type === "link_close") ||
|
|
(lastType === HASHTAG_SPAN &&
|
|
currentToken.type === "span_close" &&
|
|
currentType !== HASHTAG_ICON_SPAN)
|
|
) {
|
|
lastType = NONE;
|
|
}
|
|
}
|
|
|
|
// We scan from the end, to keep position when new tags added.
|
|
// Use reversed logic in links start/end match
|
|
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
const currentToken = tokens[i];
|
|
|
|
// Skip content of markdown links
|
|
if (currentToken.type === "link_close") {
|
|
i--;
|
|
while (
|
|
tokens[i].level !== currentToken.level &&
|
|
tokens[i].type !== "link_open"
|
|
) {
|
|
i--;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Skip content of html tag links
|
|
if (currentToken.type === "html_inline") {
|
|
if (isLinkOpen(currentToken.content) && htmlLinkLevel > 0) {
|
|
htmlLinkLevel--;
|
|
}
|
|
|
|
if (isLinkClose(currentToken.content)) {
|
|
htmlLinkLevel++;
|
|
}
|
|
}
|
|
|
|
// Skip content of mentions or hashtags
|
|
if (currentToken.skipReplace) {
|
|
continue;
|
|
}
|
|
|
|
if (currentToken.type === "text") {
|
|
const text = currentToken.content;
|
|
|
|
let matches;
|
|
if (cache.has(text)) {
|
|
matches = cache.get(text);
|
|
} else {
|
|
matches = findAllMatches(text, matchers);
|
|
cache.set(text, matches);
|
|
}
|
|
|
|
// Now split string to nodes
|
|
const nodes = [];
|
|
let level = currentToken.level;
|
|
let lastPos = 0;
|
|
|
|
let token;
|
|
for (let ln = 0; ln < matches.length; ln++) {
|
|
if (matches[ln].index < lastPos) {
|
|
continue;
|
|
}
|
|
|
|
if (matches[ln].index > lastPos) {
|
|
token = new state.Token("text", "", 0);
|
|
token.content = text.slice(lastPos, matches[ln].index);
|
|
token.level = level;
|
|
nodes.push(token);
|
|
}
|
|
|
|
if (matches[ln].link) {
|
|
const url = state.md.normalizeLink(matches[ln].replacement);
|
|
if (htmlLinkLevel === 0 && state.md.validateLink(url)) {
|
|
token = new state.Token("link_open", "a", 1);
|
|
token.attrs = [["href", url]];
|
|
if (opts.discourse.previewing) {
|
|
token.attrs.push(["data-word", ""]);
|
|
}
|
|
token.level = level++;
|
|
token.markup = "linkify";
|
|
token.info = "auto";
|
|
nodes.push(token);
|
|
|
|
token = new state.Token("text", "", 0);
|
|
token.content = matches[ln].text;
|
|
token.level = level;
|
|
nodes.push(token);
|
|
|
|
token = new state.Token("link_close", "a", -1);
|
|
token.level = --level;
|
|
token.markup = "linkify";
|
|
token.info = "auto";
|
|
nodes.push(token);
|
|
}
|
|
} else {
|
|
token = new state.Token("text", "", 0);
|
|
token.content = matches[ln].replacement;
|
|
token.level = level;
|
|
nodes.push(token);
|
|
}
|
|
|
|
lastPos = matches[ln].index + matches[ln].text.length;
|
|
}
|
|
|
|
if (lastPos < text.length) {
|
|
token = new state.Token("text", "", 0);
|
|
token.content = text.slice(lastPos);
|
|
token.level = level;
|
|
nodes.push(token);
|
|
}
|
|
|
|
// replace current node
|
|
state.tokens[j].children = tokens = md.utils.arrayReplaceAt(
|
|
tokens,
|
|
i,
|
|
nodes
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|