FIX: Do not cook icon with hashtags (#21676)

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.
This commit is contained in:
Martin Brennan 2023-05-23 09:33:55 +02:00 committed by GitHub
parent ecb9a27e55
commit 0b3cf83e3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 235 additions and 102 deletions

View File

@ -25,8 +25,7 @@ export default {
let generatedCssClasses = [];
Object.values(getHashtagTypeClasses()).forEach((hashtagTypeClass) => {
const hashtagType = new hashtagTypeClass(container);
Object.values(getHashtagTypeClasses()).forEach((hashtagType) => {
hashtagType.preloadedData.forEach((model) => {
generatedCssClasses = generatedCssClasses.concat(
hashtagType.generateColorCssClasses(model)

View File

@ -0,0 +1,24 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import { replaceHashtagIconPlaceholder } from "discourse/lib/hashtag-autocomplete";
export default {
name: "hashtag-post-decorations",
after: "hashtag-css-generator",
initialize(container) {
const siteSettings = container.lookup("service:site-settings");
const site = container.lookup("service:site");
withPluginApi("0.8.7", (api) => {
if (siteSettings.enable_experimental_hashtag_autocomplete) {
api.decorateCookedElement(
(post) => replaceHashtagIconPlaceholder(post, site),
{
onlyStream: true,
id: "hashtag-icons",
}
);
}
});
},
};

View File

@ -6,10 +6,10 @@ export default {
name: "register-hashtag-types",
before: "hashtag-css-generator",
initialize() {
initialize(container) {
withPluginApi("0.8.7", (api) => {
api.registerHashtagType("category", CategoryHashtagType);
api.registerHashtagType("tag", TagHashtagType);
api.registerHashtagType("category", new CategoryHashtagType(container));
api.registerHashtagType("tag", new TagHashtagType(container));
});
},
};

View File

@ -46,7 +46,7 @@ export function isValidLink(link) {
return (
link.classList.contains("track-link") ||
!link.closest(
".hashtag, .hashtag-cooked, .badge-category, .onebox-result, .onebox-body"
".hashtag, .hashtag-cooked, .hashtag-icon-placeholder, .badge-category, .onebox-result, .onebox-body"
)
);
}

View File

@ -1,4 +1,5 @@
import { findRawTemplate } from "discourse-common/lib/raw-templates";
import domFromString from "discourse-common/lib/dom-from-string";
import discourseLater from "discourse-common/lib/later";
import { INPUT_DELAY, isTesting } from "discourse-common/config/environment";
import { cancel } from "@ember/runloop";
@ -15,8 +16,8 @@ import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe } from "@ember/template";
let hashtagTypeClasses = {};
export function registerHashtagType(type, typeClass) {
hashtagTypeClasses[type] = typeClass;
export function registerHashtagType(type, typeClassInstance) {
hashtagTypeClasses[type] = typeClassInstance;
}
export function cleanUpHashtagTypeClasses() {
hashtagTypeClasses = {};
@ -24,6 +25,24 @@ export function cleanUpHashtagTypeClasses() {
export function getHashtagTypeClasses() {
return hashtagTypeClasses;
}
export function replaceHashtagIconPlaceholder(element, site) {
element.querySelectorAll(".hashtag-cooked").forEach((hashtagEl) => {
const iconPlaceholderEl = hashtagEl.querySelector(
".hashtag-icon-placeholder"
);
const hashtagType = hashtagEl.dataset.type;
const hashtagTypeClass = getHashtagTypeClasses()[hashtagType];
if (iconPlaceholderEl && hashtagTypeClass) {
const hashtagIconHTML = hashtagTypeClass
.generateIconHTML({
icon: site.hashtag_icons[hashtagType],
id: hashtagEl.dataset.id,
})
.trim();
iconPlaceholderEl.replaceWith(domFromString(hashtagIconHTML)[0]);
}
});
}
/**
* Sets up a textarea using the jQuery autocomplete plugin, specifically
@ -216,12 +235,12 @@ function _searchRequest(term, contextualHashtagConfiguration, resultFunc) {
data: { term, order: contextualHashtagConfiguration },
});
currentSearch
.then((r) => {
r.results?.forEach((result) => {
.then((response) => {
response.results?.forEach((result) => {
// Convert :emoji: in the result text to HTML safely.
result.text = htmlSafe(emojiUnescape(escapeExpression(result.text)));
});
resultFunc(r.results || CANCELLED_STATUS);
resultFunc(response.results || CANCELLED_STATUS);
})
.finally(() => {
currentSearch = null;
@ -235,7 +254,7 @@ function _findAndReplaceSeenHashtagPlaceholder(
hashtagSpan
) {
contextualHashtagConfiguration.forEach((type) => {
// replace raw span for the hashtag with a cooked one
// Replace raw span for the hashtag with a cooked one
const matchingSeenHashtag = seenHashtags[type]?.[slugRef];
if (matchingSeenHashtag) {
// NOTE: When changing the HTML structure here, you must also change
@ -244,8 +263,12 @@ function _findAndReplaceSeenHashtagPlaceholder(
link.classList.add("hashtag-cooked");
link.href = matchingSeenHashtag.relative_url;
link.dataset.type = type;
link.dataset.id = matchingSeenHashtag.id;
link.dataset.slug = matchingSeenHashtag.slug;
link.innerHTML = `<svg class="fa d-icon d-icon-${matchingSeenHashtag.icon} svg-icon svg-node"><use href="#${matchingSeenHashtag.icon}"></use></svg><span>${matchingSeenHashtag.text}</span>`;
const hashtagType = new getHashtagTypeClasses()[type];
link.innerHTML = `${hashtagType.generateIconHTML(
matchingSeenHashtag
)}<span>${emojiUnescape(matchingSeenHashtag.text)}</span>`;
hashtagSpan.replaceWith(link);
}
});

View File

@ -16,4 +16,8 @@ export default class HashtagTypeBase {
generateColorCssClasses() {
throw "not implemented";
}
generateIconHTML() {
throw "not implemented";
}
}

View File

@ -12,21 +12,25 @@ export default class CategoryHashtagType extends HashtagTypeBase {
return this.site.categories || [];
}
generateColorCssClasses(model) {
generateColorCssClasses(category) {
const generatedCssClasses = [];
const backgroundGradient = [`var(--category-${model.id}-color) 50%`];
if (model.parentCategory) {
const backgroundGradient = [`var(--category-${category.id}-color) 50%`];
if (category.parentCategory) {
backgroundGradient.push(
`var(--category-${model.parentCategory.id}-color) 50%`
`var(--category-${category.parentCategory.id}-color) 50%`
);
} else {
backgroundGradient.push(`var(--category-${model.id}-color) 50%`);
backgroundGradient.push(`var(--category-${category.id}-color) 50%`);
}
generatedCssClasses.push(`.hashtag-color--category-${model.id} {
generatedCssClasses.push(`.hashtag-color--category-${category.id} {
background: linear-gradient(90deg, ${backgroundGradient.join(", ")});
}`);
return generatedCssClasses;
}
generateIconHTML(hashtag) {
return `<span class="hashtag-category-badge hashtag-color--${this.type}-${hashtag.id}"></span>`;
}
}

View File

@ -1,4 +1,5 @@
import HashtagTypeBase from "./base";
import { iconHTML } from "discourse-common/lib/icon-library";
export default class TagHashtagType extends HashtagTypeBase {
get type() {
@ -12,4 +13,10 @@ export default class TagHashtagType extends HashtagTypeBase {
generateColorCssClasses() {
return [];
}
generateIconHTML(hashtag) {
return iconHTML(hashtag.icon, {
class: `hashtag-color--${this.type}-${hashtag.id}`,
});
}
}

View File

@ -2226,10 +2226,11 @@ class PluginApi {
* This is used when generating CSS classes in the hashtag-css-generator.
*
* @param {string} type - The type of the hashtag.
* @param {Class} typeClass - The class of the hashtag type.
* @param {Class} typeClassInstance - The initialized class of the hashtag type, which
* needs the `container`.
*/
registerHashtagType(type, typeClass) {
registerHashtagType(type, typeClass);
registerHashtagType(type, typeClassInstance) {
registerHashtagType(type, typeClassInstance);
}
}

View File

@ -699,7 +699,7 @@ export default {
],
displayed_about_plugin_stat_groups: ["chat_messages"],
hashtag_configurations: { "topic-composer": ["category", "tag"] },
hashtag_icons: ["folder", "tag"],
hashtag_icons: { "category": "folder", "tag": "tag" },
anonymous_sidebar_sections: [
{
id: 111,

View File

@ -29,6 +29,7 @@ function addHashtag(buffer, matches, state) {
["href", result.relative_url],
["data-type", result.type],
["data-slug", result.slug],
["data-id", result.id],
];
// Most cases these will be the exact same, one standout is categories
@ -40,20 +41,7 @@ function addHashtag(buffer, matches, state) {
token.block = false;
buffer.push(token);
token = new state.Token("svg_open", "svg", 1);
token.block = false;
token.attrs = [
["class", `fa d-icon d-icon-${result.icon} svg-icon svg-node`],
];
buffer.push(token);
token = new state.Token("use_open", "use", 1);
token.block = false;
token.attrs = [["href", `#${result.icon}`]];
buffer.push(token);
buffer.push(new state.Token("use_close", "use", -1));
buffer.push(new state.Token("svg_close", "svg", -1));
addIconPlaceholder(buffer, state);
token = new state.Token("span_open", "span", 1);
token.block = false;
@ -82,24 +70,22 @@ function addHashtag(buffer, matches, state) {
}
}
// The svg icon is not baked into the HTML because we want
// to be able to use icon replacement via renderIcon, and
// because different hashtag types may render icons/CSS
// classes differently.
//
// Instead, the UI will dynamically replace these where hashtags
// are rendered, like within posts, using decorateCooked* APIs.
function addIconPlaceholder(buffer, state) {
const token = new state.Token("span_open", "span", 1);
token.block = false;
token.attrs = [["class", "hashtag-icon-placeholder"]];
buffer.push(token);
buffer.push(new state.Token("span_close", "span", -1));
}
export function setup(helper) {
const opts = helper.getOptions();
// we do this because plugins can register their own hashtag data
// sources which specify an icon, and each icon must be allowlisted
// or it will not render in the markdown pipeline
const hashtagIconAllowList = opts.hashtagIcons
? opts.hashtagIcons
.concat(["hashtag"])
.map((icon) => {
return [
`svg[class=fa d-icon d-icon-${icon} svg-icon svg-node]`,
`use[href=#${icon}]`,
];
})
.flat()
: [];
helper.registerPlugin((md) => {
if (
md.options.discourse.limitedSiteSettings
@ -114,13 +100,13 @@ export function setup(helper) {
}
});
helper.allowList(
hashtagIconAllowList.concat([
"a.hashtag-cooked",
"span.hashtag-raw",
"a[data-type]",
"a[data-slug]",
"a[data-ref]",
])
);
helper.allowList([
"a.hashtag-cooked",
"span.hashtag-raw",
"span.hashtag-icon-placeholder",
"a[data-type]",
"a[data-slug]",
"a[data-ref]",
"a[data-id]",
]);
}

View File

@ -43,6 +43,7 @@ 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();
@ -97,6 +98,7 @@ export function setup(helper) {
// 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];
@ -109,14 +111,26 @@ export function setup(helper) {
currentToken.attrs.some(
(attr) =>
attr[0] === "class" &&
(attr[1].includes("hashtag") ||
attr[1].includes("hashtag-cooked"))
(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;
}
@ -124,7 +138,9 @@ export function setup(helper) {
if (
(lastType === MENTION && currentToken.type === "mention_close") ||
(lastType === HASHTAG_LINK && currentToken.type === "link_close") ||
(lastType === HASHTAG_SPAN && currentToken.type === "span_close")
(lastType === HASHTAG_SPAN &&
currentToken.type === "span_close" &&
currentType !== HASHTAG_ICON_SPAN)
) {
lastType = NONE;
}

View File

@ -37,6 +37,14 @@ a.hashtag-cooked {
svg {
display: inline;
}
.hashtag-category-badge {
flex: 0 0 auto;
width: 9px;
height: 9px;
margin-right: 5px;
display: inline-block;
}
}
.hashtag-autocomplete {

View File

@ -241,7 +241,7 @@ class SiteSerializer < ApplicationSerializer
end
def hashtag_icons
HashtagAutocompleteService.data_source_icons
HashtagAutocompleteService.data_source_icon_map
end
def displayed_about_plugin_stat_groups

View File

@ -19,6 +19,7 @@ class CategoryHashtagDataSource
item.description = category.description_text
item.icon = icon
item.relative_url = category.url
item.id = category.id
# Single-level category heirarchy should be enough to distinguish between
# categories here.

View File

@ -34,8 +34,8 @@ class HashtagAutocompleteService
data_sources.map(&:type)
end
def self.data_source_icons
data_sources.map(&:icon)
def self.data_source_icon_map
data_sources.map { |ds| [ds.type, ds.icon] }.to_h
end
def self.data_source_from_type(type)
@ -88,6 +88,10 @@ class HashtagAutocompleteService
# item, used for the cooked hashtags, e.g. /c/2/staff
attr_accessor :relative_url
# The ID of the resource that is represented by the autocomplete item,
# e.g. category.id, tag.id
attr_accessor :id
def initialize(params = {})
@relative_url = params[:relative_url]
@text = params[:text]
@ -96,6 +100,7 @@ class HashtagAutocompleteService
@type = params[:type]
@ref = params[:ref]
@slug = params[:slug]
@id = params[:id]
end
def to_h
@ -107,6 +112,7 @@ class HashtagAutocompleteService
type: self.type,
ref: self.ref,
slug: self.slug,
id: self.id,
}
end
end

View File

@ -27,6 +27,7 @@ class TagHashtagDataSource
item.slug = tag.name
item.relative_url = tag.url
item.icon = icon
item.id = tag.id
end
end
private_class_method :tag_to_hashtag_item
@ -66,7 +67,11 @@ class TagHashtagDataSource
TagsController
.tag_counts_json(tags_with_counts, guardian)
.take(limit)
.map { |tag| tag_to_hashtag_item(tag, guardian) }
.map do |tag|
# We want the actual ID here not the `name` as tag_counts_json gives us.
tag[:id] = tags_with_counts.find { |t| t.name == tag[:name] }.id
tag_to_hashtag_item(tag, guardian)
end
end
def self.search_sort(search_results, _)

View File

@ -22,6 +22,7 @@ class ExcerptParser < Nokogiri::XML::SAX::Document
@keep_svg = options[:keep_svg] == true
@remap_emoji = options[:remap_emoji] == true
@start_excerpt = false
@start_hashtag_icon = false
@in_details_depth = 0
@summary_contents = +""
@detail_contents = +""
@ -112,10 +113,14 @@ class ExcerptParser < Nokogiri::XML::SAX::Document
when "header"
@in_quote = !@keep_onebox_source if attributes.include?(%w[class source])
when "div", "span"
if attributes.include?(%w[class excerpt])
attributes = Hash[*attributes.flatten]
if attributes["class"]&.include?("excerpt")
@excerpt = +""
@current_length = 0
@start_excerpt = true
elsif attributes["class"]&.include?("hashtag-icon-placeholder")
@start_hashtag_icon = true
include_tag(name, attributes)
end
when "details"
@detail_contents = +"" if @in_details_depth == 0
@ -180,6 +185,7 @@ class ExcerptParser < Nokogiri::XML::SAX::Document
@in_summary = false if @in_details_depth == 1
when "div", "span"
throw :done if @start_excerpt
characters("</span>", truncate: false, count_it: false, encode: false) if @start_hashtag_icon
when "svg"
characters("</svg>", truncate: false, count_it: false, encode: false) if @keep_svg
@in_svg = false

View File

@ -224,10 +224,8 @@ module PrettyText
.ordered_types_for_context(opts[:hashtag_context])
.map { |t| "'#{t}'" }
.join(",")
hashtag_icons_as_js =
HashtagAutocompleteService.data_source_icons.map { |i| "'#{i}'" }.join(",")
buffer << "__optInput.hashtagTypesInPriorityOrder = [#{hashtag_types_as_js}];\n"
buffer << "__optInput.hashtagIcons = [#{hashtag_icons_as_js}];\n"
buffer << "__optInput.hashtagIcons = #{HashtagAutocompleteService.data_source_icon_map.to_json};\n"
buffer << "__textOptions = __buildOptions(__optInput);\n"
buffer << ("__pt = new __PrettyText(__textOptions);")

View File

@ -1,4 +1,5 @@
import { decorateGithubOneboxBody } from "discourse/initializers/onebox-decorators";
import { replaceHashtagIconPlaceholder } from "discourse/lib/hashtag-autocomplete";
import { withPluginApi } from "discourse/lib/plugin-api";
import highlightSyntax from "discourse/lib/highlight-syntax";
import I18n from "I18n";
@ -12,6 +13,7 @@ export default {
initializeWithPluginApi(api, container) {
const siteSettings = container.lookup("service:site-settings");
const site = container.lookup("service:site");
api.decorateChatMessage((element) => decorateGithubOneboxBody(element), {
id: "onebox-github-body",
});
@ -70,6 +72,11 @@ export default {
id: "lightbox",
}
);
api.decorateChatMessage(
(element) => replaceHashtagIconPlaceholder(element, site),
{ id: "hashtagIcons" }
);
},
_getScrollParent(node, maxParentSelector) {

View File

@ -28,7 +28,7 @@ export default {
}
withPluginApi("0.12.1", (api) => {
api.registerHashtagType("channel", ChannelHashtagType);
api.registerHashtagType("channel", new ChannelHashtagType(container));
api.registerChatComposerButton({
id: "chat-upload-btn",

View File

@ -1,4 +1,5 @@
import HashtagTypeBase from "discourse/lib/hashtag-types/base";
import { iconHTML } from "discourse-common/lib/icon-library";
import { inject as service } from "@ember/service";
export default class ChannelHashtagType extends HashtagTypeBase {
@ -17,9 +18,15 @@ export default class ChannelHashtagType extends HashtagTypeBase {
}
}
generateColorCssClasses(model) {
generateColorCssClasses(channel) {
return [
`.hashtag-color--${this.type}-${model.id} { color: var(--category-${model.chatable.id}-color); }`,
`.hashtag-cooked .d-icon.hashtag-color--${this.type}-${channel.id} { color: var(--category-${channel.chatable.id}-color); }`,
];
}
generateIconHTML(hashtag) {
return iconHTML(hashtag.icon, {
class: `hashtag-color--${this.type}-${hashtag.id}`,
});
}
}

View File

@ -18,6 +18,7 @@ module Chat
item.icon = icon
item.relative_url = channel.relative_url
item.type = "channel"
item.id = channel.id
end
end

View File

@ -195,7 +195,7 @@ describe Chat::ChannelArchiveService do
expect(@channel_archive.reload.complete?).to eq(true)
pm_topic = Topic.private_messages.last
expect(pm_topic.first_post.cooked).to include(
"<a class=\"hashtag-cooked\" href=\"#{channel.relative_url}\" data-type=\"channel\" data-slug=\"#{channel.slug}\" data-ref=\"#{channel.slug}::channel\"><svg class=\"fa d-icon d-icon-comment svg-icon svg-node\"><use href=\"#comment\"></use></svg><span>#{channel.title(user)}</span></a>",
"<a class=\"hashtag-cooked\" href=\"#{channel.relative_url}\" data-type=\"channel\" data-slug=\"#{channel.slug}\" data-id=\"#{channel.id}\" data-ref=\"#{channel.slug}::channel\"><span class=\"hashtag-icon-placeholder\"></span><span>#{channel.title(user)}</span></a>",
)
end
end

View File

@ -41,6 +41,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Zany Things",
description: "Just weird stuff",
icon: "comment",
id: channel1.id,
type: "channel",
ref: nil,
slug: "random",
@ -60,6 +61,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Secret Stuff",
description: nil,
icon: "comment",
id: channel2.id,
type: "channel",
ref: nil,
slug: "secret",
@ -94,6 +96,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Zany Things",
description: "Just weird stuff",
icon: "comment",
id: channel1.id,
type: "channel",
ref: nil,
slug: "random",
@ -109,6 +112,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Zany Things",
description: "Just weird stuff",
icon: "comment",
id: channel1.id,
type: "channel",
ref: nil,
slug: "random",
@ -127,6 +131,7 @@ RSpec.describe Chat::ChannelHashtagDataSource do
text: "Secret Stuff",
description: nil,
icon: "comment",
id: channel2.id,
type: "channel",
ref: nil,
slug: "secret",

View File

@ -259,7 +259,7 @@ describe Chat::Message do
cooked = described_class.cook("##{category.slug}", user_id: user.id)
expect(cooked).to eq(
"<p><a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"#{category.slug}\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>#{category.name}</span></a></p>",
"<p><a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"#{category.slug}\" data-id=\"#{category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>#{category.name}</span></a></p>",
)
end

View File

@ -71,13 +71,13 @@ describe "Using #hashtag autocompletion to search for and lookup channels",
cooked_hashtags = page.all(".hashtag-cooked", count: 3)
expect(cooked_hashtags[0]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{channel2.relative_url}\" data-type=\"channel\" data-slug=\"random\"><svg class=\"fa d-icon d-icon-comment svg-icon svg-node\"><use href=\"#comment\"></use></svg><span>Random</span></a>
<a class=\"hashtag-cooked\" href=\"#{channel2.relative_url}\" data-type=\"channel\" data-slug=\"random\" data-id=\"#{channel2.id}\"><svg class=\"fa d-icon d-icon-comment svg-icon hashtag-color--channel-#{channel2.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#comment\"></use></svg><span>Random</span></a>
HTML
expect(cooked_hashtags[1]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"raspberry-beret\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>Raspberry</span></a>
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"raspberry-beret\" data-id="#{category.id}"><span class=\"hashtag-category-badge hashtag-color--category-#{category.id}\"></span><span>Raspberry</span></a>
HTML
expect(cooked_hashtags[2]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"razed\"><svg class=\"fa d-icon d-icon-tag svg-icon svg-node\"><use href=\"#tag\"></use></svg><span>razed</span></a>
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"razed\" data-id="#{tag.id}"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>razed</span></a>
HTML
end
end

View File

@ -59,7 +59,7 @@ acceptance("Chat | Hashtag CSS Generator", function (needs) {
assert.equal(
cssTag.innerHTML,
".hashtag-color--category-1 {\n background: linear-gradient(90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--category-2 {\n background: linear-gradient(90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);\n}\n.hashtag-color--category-4 {\n background: linear-gradient(90deg, var(--category-4-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--channel-44 { color: var(--category-1-color); }\n.hashtag-color--channel-74 { color: var(--category-2-color); }\n.hashtag-color--channel-88 { color: var(--category-4-color); }"
".hashtag-color--category-1 {\n background: linear-gradient(90deg, var(--category-1-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-color--category-2 {\n background: linear-gradient(90deg, var(--category-2-color) 50%, var(--category-2-color) 50%);\n}\n.hashtag-color--category-4 {\n background: linear-gradient(90deg, var(--category-4-color) 50%, var(--category-1-color) 50%);\n}\n.hashtag-cooked .d-icon.hashtag-color--channel-44 { color: var(--category-1-color); }\n.hashtag-cooked .d-icon.hashtag-color--channel-74 { color: var(--category-2-color); }\n.hashtag-cooked .d-icon.hashtag-color--channel-88 { color: var(--category-4-color); }"
);
});
});

View File

@ -572,7 +572,7 @@ RSpec.describe Email::Sender do
reply.rebake!
Email::Sender.new(message, :valid_type).send
expected = <<~HTML
<a href=\"#{Discourse.base_url}#{category.url}\" data-type=\"category\" data-slug=\"dev\" style=\"text-decoration: none; font-weight: bold; color: #006699;\"><span>#dev</span>
<a href=\"#{Discourse.base_url}#{category.url}\" data-type=\"category\" data-slug=\"dev\" data-id=\"#{category.id}\" style=\"text-decoration: none; font-weight: bold; color: #006699;\"><span>#dev</span>
HTML
expect(message.html_part.body.to_s).to include(expected.chomp)
end

View File

@ -178,13 +178,20 @@ RSpec.describe Oneboxer do
expect(preview("/u/#{user.username}")).to include("Thunderland")
end
it "includes hashtag HTML and icons" do
it "includes hashtag HTML" do
SiteSetting.enable_experimental_hashtag_autocomplete = true
category = Fabricate(:category, slug: "random")
Fabricate(:tag, name: "bug")
tag = Fabricate(:tag, name: "bug")
public_post = Fabricate(:post, raw: "This post has some hashtags, #random and #bug")
expect(preview(public_post.url).chomp).to include(<<~HTML.chomp)
<a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="random"><svg class="fa d-icon d-icon-folder svg-icon svg-node"><use href="#folder"></use></svg>#{category.name}</a> and <a class="hashtag-cooked" href="/tag/bug" data-type="tag" data-slug="bug"><svg class="fa d-icon d-icon-tag svg-icon svg-node"><use href="#tag"></use></svg>bug</a>
preview =
Nokogiri::HTML5
.fragment(preview(public_post.url).chomp)
.css("blockquote")
.inner_html
.chomp
.strip
expect(preview).to eq(<<~HTML.chomp.strip)
This post has some hashtags, <a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="random" data-id="#{category.id}"><span class="hashtag-icon-placeholder"></span>#{category.name}</a> and <a class="hashtag-cooked" href="#{tag.url}" data-type="tag" data-slug="bug" data-id="#{tag.id}"><span class="hashtag-icon-placeholder"></span>#{tag.name}</a>
HTML
end
end

View File

@ -68,6 +68,7 @@ RSpec.describe PrettyText::Helpers do
text: "somecooltag",
description: "Coolest things ever",
icon: "tag",
id: tag.id,
slug: "somecooltag",
ref: "somecooltag::tag",
type: "tag",
@ -85,6 +86,7 @@ RSpec.describe PrettyText::Helpers do
text: "Some Awesome Category",
description: "Really great stuff here",
icon: "folder",
id: category.id,
slug: "someawesomecategory",
ref: "someawesomecategory::category",
type: "category",
@ -101,6 +103,7 @@ RSpec.describe PrettyText::Helpers do
text: "Some Awesome Category",
description: "Really great stuff here",
icon: "folder",
id: category.id,
slug: "someawesomecategory",
ref: "someawesomecategory",
type: "category",
@ -115,6 +118,7 @@ RSpec.describe PrettyText::Helpers do
text: "somecooltag",
description: "Coolest things ever",
icon: "tag",
id: tag.id,
slug: "somecooltag",
ref: "somecooltag",
type: "tag",
@ -128,6 +132,7 @@ RSpec.describe PrettyText::Helpers do
text: "Some Awesome Category",
description: "Really great stuff here",
icon: "folder",
id: category.id,
slug: "someawesomecategory",
ref: "someawesomecategory",
type: "category",
@ -150,6 +155,7 @@ RSpec.describe PrettyText::Helpers do
text: "Manager Hideout",
description: nil,
icon: "folder",
id: private_category.id,
slug: "secretcategory",
ref: "secretcategory",
type: "category",

View File

@ -1715,19 +1715,20 @@ RSpec.describe PrettyText do
category2 = Fabricate(:category, name: "known", slug: "known")
group = Fabricate(:group)
private_category = Fabricate(:private_category, name: "secret", group: group, slug: "secret")
Fabricate(:topic, tags: [Fabricate(:tag, name: "known")])
tag = Fabricate(:tag, name: "known")
Fabricate(:topic, tags: [tag])
cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id)
expect(cooked).to include("<span class=\"hashtag-raw\">#unknown::tag</span>")
expect(cooked).to include(
"<a class=\"hashtag-cooked\" href=\"#{category2.url}\" data-type=\"category\" data-slug=\"known\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>known</span></a>",
"<a class=\"hashtag-cooked\" href=\"#{category2.url}\" data-type=\"category\" data-slug=\"known\" data-id=\"#{category2.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a>",
)
expect(cooked).to include(
"<a class=\"hashtag-cooked\" href=\"/tag/known\" data-type=\"tag\" data-slug=\"known\" data-ref=\"known::tag\"><svg class=\"fa d-icon d-icon-tag svg-icon svg-node\"><use href=\"#tag\"></use></svg><span>known</span></a>",
"<a class=\"hashtag-cooked\" href=\"/tag/known\" data-type=\"tag\" data-slug=\"known\" data-id=\"#{tag.id}\" data-ref=\"known::tag\"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a>",
)
expect(cooked).to include(
"<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"testing\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>testing</span></a>",
"<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"testing\" data-id=\"#{category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>testing</span></a>",
)
expect(cooked).to include("<span class=\"hashtag-raw\">#secret</span>")
@ -1735,7 +1736,7 @@ RSpec.describe PrettyText do
group.add(user)
cooked = PrettyText.cook(" #unknown::tag #known #known::tag #testing #secret", user_id: user.id)
expect(cooked).to include(
"<a class=\"hashtag-cooked\" href=\"#{private_category.url}\" data-type=\"category\" data-slug=\"secret\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>secret</span></a>",
"<a class=\"hashtag-cooked\" href=\"#{private_category.url}\" data-type=\"category\" data-slug=\"secret\" data-id=\"#{private_category.id}\"><span class=\"hashtag-icon-placeholder\"></span><span>secret</span></a>",
)
cooked = PrettyText.cook("[`a` #known::tag here](http://example.com)", user_id: user.id)
@ -1753,7 +1754,7 @@ RSpec.describe PrettyText do
cooked = PrettyText.cook("<A href='/a'>test</A> #known::tag", user_id: user.id)
html = <<~HTML
<p><a href="/a">test</a> <a class="hashtag-cooked" href="/tag/known" data-type="tag" data-slug="known" data-ref="known::tag"><svg class="fa d-icon d-icon-tag svg-icon svg-node"><use href="#tag"></use></svg><span>known</span></a></p>
<p><a href="/a">test</a> <a class="hashtag-cooked" href="/tag/known" data-type="tag" data-slug="known" data-id=\"#{tag.id}\" data-ref="known::tag"><span class=\"hashtag-icon-placeholder\"></span><span>known</span></a></p>
HTML
expect(cooked).to eq(html.strip)
@ -2006,7 +2007,7 @@ HTML
expect(PrettyText.cook("@test #test test")).to match_html(<<~HTML)
<p>
<a class="mention" href="/u/test">@test</a>
<a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="test"><svg class="fa d-icon d-icon-folder svg-icon svg-node"><use href="#folder"></use></svg><span>test</span></a>
<a class="hashtag-cooked" href="#{category.url}" data-type="category" data-slug="test" data-id="#{category.id}"><span class="hashtag-icon-placeholder"></span><span>test</span></a>
tdiscourset
</p>
HTML

View File

@ -524,7 +524,7 @@
"type": "object"
},
"hashtag_icons" : {
"type": "array"
"type": "object"
},
"displayed_about_plugin_stat_groups" : {
"type": "array"

View File

@ -163,6 +163,7 @@ RSpec.describe HashtagsController do
"type" => "category",
"ref" => category.slug,
"slug" => category.slug,
"id" => category.id,
},
],
"tag" => [
@ -175,6 +176,7 @@ RSpec.describe HashtagsController do
"ref" => tag.name,
"slug" => tag.name,
"secondary_text" => "x0",
"id" => tag.id,
},
],
},
@ -198,6 +200,7 @@ RSpec.describe HashtagsController do
"ref" => "#{tag.name}::tag",
"slug" => tag.name,
"secondary_text" => "x0",
"id" => tag.id,
},
],
},
@ -242,6 +245,7 @@ RSpec.describe HashtagsController do
"type" => "category",
"ref" => private_category.slug,
"slug" => private_category.slug,
"id" => private_category.id,
},
],
"tag" => [
@ -254,6 +258,7 @@ RSpec.describe HashtagsController do
"ref" => hidden_tag.name,
"slug" => hidden_tag.name,
"secondary_text" => "x0",
"id" => hidden_tag.id,
},
],
},
@ -337,6 +342,7 @@ RSpec.describe HashtagsController do
"type" => "category",
"ref" => category.slug,
"slug" => category.slug,
"id" => category.id,
},
{
"relative_url" => tag_2.url,
@ -347,6 +353,7 @@ RSpec.describe HashtagsController do
"ref" => "#{tag_2.name}::tag",
"slug" => tag_2.name,
"secondary_text" => "x0",
"id" => tag_2.id,
},
],
)
@ -379,6 +386,7 @@ RSpec.describe HashtagsController do
"type" => "category",
"ref" => private_category.slug,
"slug" => private_category.slug,
"id" => private_category.id,
},
],
)
@ -396,6 +404,7 @@ RSpec.describe HashtagsController do
"ref" => "#{hidden_tag.name}",
"slug" => hidden_tag.name,
"secondary_text" => "x0",
"id" => hidden_tag.id,
},
],
)

View File

@ -32,9 +32,11 @@ RSpec.describe HashtagAutocompleteService do
end
end
describe ".data_source_icons" do
describe ".data_source_icon_map" do
it "gets an array for all icons defined by data sources so they can be used for markdown allowlisting" do
expect(HashtagAutocompleteService.data_source_icons).to eq(%w[folder tag])
expect(HashtagAutocompleteService.data_source_icon_map).to eq(
{ "category" => "folder", "tag" => "tag" },
)
end
end

View File

@ -61,7 +61,7 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
expect(page).to have_css(".hashtag-cooked")
cooked_hashtag = page.find(".hashtag-cooked")
expected = <<~HTML.chomp
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"cool-cat\" tabindex=\"-1\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>Cool Category</span></a>
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-id=\"#{category.id}\" data-slug=\"cool-cat\" tabindex=\"-1\"><span class="hashtag-category-badge hashtag-color--category-#{category.id}"></span><span>Cool Category</span></a>
HTML
expect(cooked_hashtag["outerHTML"].squish).to eq(expected)
@ -71,7 +71,7 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
expect(page).to have_css(".hashtag-cooked")
cooked_hashtag = page.find(".hashtag-cooked")
expect(cooked_hashtag["outerHTML"].squish).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"cooltag\" tabindex=\"-1\"><svg class=\"fa d-icon d-icon-tag svg-icon svg-node\"><use href=\"#tag\"></use></svg><span>cooltag</span></a>
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-id=\"#{tag.id}\" data-slug=\"cooltag\" tabindex=\"-1\"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>cooltag</span></a>
HTML
end
@ -84,10 +84,10 @@ describe "Using #hashtag autocompletion to search for and lookup categories and
cooked_hashtags = page.all(".hashtag-cooked", count: 2)
expect(cooked_hashtags[0]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"cool-cat\"><svg class=\"fa d-icon d-icon-folder svg-icon svg-node\"><use href=\"#folder\"></use></svg><span>Cool Category</span></a>
<a class=\"hashtag-cooked\" href=\"#{category.url}\" data-type=\"category\" data-slug=\"cool-cat\" data-id=\"#{category.id}\"><span class=\"hashtag-category-badge hashtag-color--category-#{category.id}\"></span><span>Cool Category</span></a>
HTML
expect(cooked_hashtags[1]["outerHTML"]).to eq(<<~HTML.chomp)
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"cooltag\"><svg class=\"fa d-icon d-icon-tag svg-icon svg-node\"><use href=\"#tag\"></use></svg><span>cooltag</span></a>
<a class=\"hashtag-cooked\" href=\"#{tag.url}\" data-type=\"tag\" data-slug=\"cooltag\" data-id=\"#{tag.id}\"><svg class=\"fa d-icon d-icon-tag svg-icon hashtag-color--tag-#{tag.id} svg-string\" xmlns=\"http://www.w3.org/2000/svg\"><use href=\"#tag\"></use></svg><span>cooltag</span></a>
HTML
end
end