Skip to content

Commit 792548a

Browse files
authored
Merge pull request #1860 from codidact/0valt/1857/markdown-in-titles
Allow Markdown in post titles
2 parents a131fa7 + 9aa2bef commit 792548a

11 files changed

Lines changed: 182 additions & 85 deletions

File tree

app/assets/stylesheets/application.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,5 +308,4 @@ kbd {
308308
left: 0.25em;
309309
right: 0.25em;
310310
};
311-
vertical-align: text-bottom;
312311
}

app/assets/stylesheets/posts.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ h1 .badge.is-tag.is-master-tag {
5858
width: 100%;
5959
overflow-wrap: break-word;
6060
}
61+
62+
del {
63+
background-color: transparent;
64+
}
6165
}
6266

6367
.post-list--content {
@@ -145,6 +149,10 @@ h1 .badge.is-tag.is-master-tag {
145149
width: 100%;
146150
}
147151

152+
del {
153+
background-color: transparent;
154+
}
155+
148156
@media screen and (min-width: $screen-md) {
149157
flex-direction: row;
150158

app/helpers/application_helper.rb

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,12 +114,29 @@ def short_number_to_human(*args, **opts)
114114

115115
##
116116
# Render a markdown string to HTML with consistent options.
117-
# @param markdown [String] The markdown string to render.
118-
# @return [String] The rendered HTML string.
119-
def render_markdown(markdown)
120-
CommonMarker.render_doc(markdown,
121-
[:FOOTNOTES, :LIBERAL_HTML_TAG, :STRIKETHROUGH_DOUBLE_TILDE],
122-
[:table, :strikethrough, :autolink]).to_html(:UNSAFE)
117+
# @param markdown [String] markdown string to render
118+
# @option opts :footnotes [Boolean] render footnotes?
119+
# @option opts :strikethrough [Boolean] render strikethrough?
120+
# @option opts :tables [Boolean] render tables?
121+
# @return [String] rendered HTML string
122+
def render_markdown(markdown, **opts)
123+
extensions = [:autolink]
124+
options = [:LIBERAL_HTML_TAG]
125+
126+
unless opts[:footnotes] == false
127+
options << :FOOTNOTES
128+
end
129+
130+
unless opts[:strikethrough] == false
131+
extensions << :strikethrough
132+
options << :STRIKETHROUGH_DOUBLE_TILDE
133+
end
134+
135+
unless opts[:tables] == false
136+
extensions << :table
137+
end
138+
139+
CommonMarker.render_doc(markdown, options, extensions).to_html(:UNSAFE)
123140
end
124141

125142
##

app/helpers/posts_helper.rb

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ def post_user_link(post, active: false)
88
user_link(user, { host: post.community.host })
99
end
1010

11+
# Renders title for a given post
12+
# @param post [Post] post to render the title for
13+
# @return [ActiveSupport::SafeBuffer] rendered title
14+
def rendered_title(post)
15+
raw_title = post.top_level? ? post.title : post.parent.title
16+
sanitize(render_markdown(raw_title), scrubber: title_scrubber)
17+
end
18+
1119
##
1220
# Get HTML for a field - should only be used in Markdown create/edit requests. Prioritises using the client-side
1321
# rendered HTML over rendering server-side.
@@ -76,6 +84,31 @@ def max_title_length(_category)
7684
[SiteSetting['MaxTitleLength'] || 255, 255].min
7785
end
7886

87+
class PostTitleScrubber < Rails::HTML::PermitScrubber
88+
def initialize
89+
super
90+
91+
attrs = []
92+
tags = []
93+
94+
allowed_types = SiteSetting['AllowedPostTitleFormattingTypes'] || ['code', 'italic', 'keyboard']
95+
tags.push('del', 'strike') if allowed_types.include?('strikethrough')
96+
tags << 'code' if allowed_types.include?('code')
97+
tags << 'kbd' if allowed_types.include?('keyboard')
98+
tags << 'em' if allowed_types.include?('italic')
99+
tags << 'strong' if allowed_types.include?('bold')
100+
tags << 'sub' if allowed_types.include?('subscript')
101+
tags << 'sup' if allowed_types.include?('superscript')
102+
103+
self.tags = tags
104+
self.attributes = attrs
105+
end
106+
107+
def skip_node?(node)
108+
node.text?
109+
end
110+
end
111+
79112
class PostScrubber < Rails::Html::PermitScrubber
80113
ALLOWED_ATTRS = %w[id class href title src height width alt rowspan colspan lang start dir].freeze
81114

@@ -100,4 +133,10 @@ def skip_node?(node)
100133
def scrubber
101134
PostsHelper::PostScrubber.new
102135
end
136+
137+
# Get a post title scrubber instance
138+
# @return [PostTitleScrubber]
139+
def title_scrubber
140+
PostsHelper::PostTitleScrubber.new
141+
end
103142
end
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1-
# Provides helper methods for use by views under <tt>SiteSettingsController</tt>.
21
module SiteSettingsHelper
2+
# Renders description for a given site setting
3+
# @param setting [SiteSetting] setting to render the description for
4+
# @return [ActiveSupport::SafeBuffer] rendered description
5+
def rendered_description(setting)
6+
raw_description = setting.description || ''
7+
sanitize(render_markdown(raw_description))
8+
end
39
end

app/models/post.rb

Lines changed: 71 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,6 @@ def tag_set
115115
parent.nil? ? category.tag_set : parent.category.tag_set
116116
end
117117

118-
# @return [Boolean]
119-
def meta?
120-
false
121-
end
122-
123118
# Used in the transfer of content from SE to reassign the owner of a post to the given user.
124119
# @param new_user [User]
125120
def reassign_user(new_user)
@@ -167,37 +162,6 @@ def body_plain
167162
ApplicationController.helpers.strip_markdown(body_markdown)
168163
end
169164

170-
# @return [Boolean] whether this post is a question
171-
def question?
172-
post_type_id == Question.post_type_id
173-
end
174-
175-
# @return [Boolean] whether this post is an answer
176-
def answer?
177-
post_type_id == Answer.post_type_id
178-
end
179-
180-
# @return [Boolean] whether this post is an article
181-
def article?
182-
post_type_id == Article.post_type_id
183-
end
184-
185-
# @return [Boolean] whether the post can be closed
186-
def closeable?
187-
post_type.is_closeable
188-
end
189-
190-
# Is the post deleted by the owner?
191-
# @return [Boolean] check result
192-
def deleted_by_owner?
193-
deleted_by&.same_as?(user)
194-
end
195-
196-
# @return [Boolean] whether there is a suggested edit pending for this post
197-
def pending_suggested_edit?
198-
SuggestedEdit.where(post_id: id, active: true).any?
199-
end
200-
201165
# @return [SuggestedEdit, Nil] the suggested edit pending for this post (if any)
202166
def pending_suggested_edit
203167
SuggestedEdit.where(post_id: id, active: true).last
@@ -216,29 +180,6 @@ def recalc_score
216180
clear_attribute_changes([:score])
217181
end
218182

219-
# Checks whether the post allows users to comment on it
220-
# @return [Boolean] check result
221-
def comments_allowed?
222-
!locked? && !deleted && !comments_disabled
223-
end
224-
225-
# The test here is for flags that are pending (no status). A spam flag
226-
# could be marked helpful but the post wouldn't be deleted, and
227-
# we don't necessarily want the post to be treated like it's a spam risk
228-
# if that happens.
229-
def spam_flag_pending?
230-
flags.any? { |flag| flag.post_flag_type&.name == "it's spam" && !flag.status }
231-
end
232-
233-
# Checks whether a given user can access the post at all
234-
# @param user [User, Nil] user to check access for
235-
# @return [Boolean] access check result
236-
def can_access?(user)
237-
(!deleted? || user&.post_privilege?('flag_curate', self)) &&
238-
(!category.present? || !category.min_view_trust_level.present? ||
239-
category.min_view_trust_level <= (user&.trust_level || 0))
240-
end
241-
242183
# Maps reaction types to number of reactions of that type
243184
# @return [Hash{ReactionType => Integer}]
244185
def reaction_list
@@ -257,19 +198,85 @@ def related_posts_for(user)
257198
end
258199
end
259200

201+
# predicates:
202+
203+
# Is the post an Answer?
204+
# @return [Boolean] check result
205+
def answer?
206+
post_type_id == Answer.post_type_id
207+
end
208+
209+
# Is the post an Article?
210+
# @return [Boolean] check result
211+
def article?
212+
post_type_id == Article.post_type_id
213+
end
214+
215+
# Can a given user access the post at all?
216+
# @param user [User, Nil] user to check access for
217+
# @return [Boolean] check result
218+
def can_access?(user)
219+
(!deleted? || user&.post_privilege?('flag_curate', self)) &&
220+
(!category.present? || !category.min_view_trust_level.present? ||
221+
category.min_view_trust_level <= (user&.trust_level || 0))
222+
end
223+
224+
# Can the post be closed?
225+
# @return [Boolean] check result
226+
def closeable?
227+
post_type.is_closeable
228+
end
229+
230+
# Are comments allowed on the post?
231+
# @return [Boolean] check result
232+
def comments_allowed?
233+
!locked? && !deleted && !comments_disabled
234+
end
235+
236+
# Is the post deleted by the owner?
237+
# @return [Boolean] check result
238+
def deleted_by_owner?
239+
deleted_by&.same_as?(user)
240+
end
241+
242+
# Is a given user following the post?
243+
# @param user [User] user to check
244+
# @return [Boolean] check result
245+
def followed_by?(user)
246+
ThreadFollower.where(post: self, user: user).any?
247+
end
248+
249+
# Does the post have any pending (not handled) spam flags?
250+
# A spam flag could be marked helpful but the post wouldn't be deleted, and
251+
# we don't want the post to be treated like it's spam risk if that happens.
252+
# @return [Boolean] check result
253+
def pending_spam_flag?
254+
flags.any? { |flag| flag.post_flag_type&.name == "it's spam" && !flag.status }
255+
end
256+
257+
# Does the post have a pending suggested edit?
258+
# @return [Boolean] check result
259+
def pending_suggested_edit?
260+
SuggestedEdit.where(post_id: id, active: true).any?
261+
end
262+
260263
# Checks if the post has related posts (scoped for a given user)
261264
# @param user [User, nil] user to check access for
262265
# @return [Boolean] check result
263266
def related_posts_for?(user)
264267
related_posts_for(user).any?
265268
end
266269

267-
# Are new threads on this post followed by a given user?
268-
# @param post [Post] post to check
269-
# @param user [User] user to check
270+
# Is the post a top level post?
270271
# @return [Boolean] check result
271-
def followed_by?(user)
272-
ThreadFollower.where(post: self, user: user).any?
272+
def top_level?
273+
post_type.is_top_level
274+
end
275+
276+
# Is the post a Question?
277+
# @return [Boolean] check result
278+
def question?
279+
post_type_id == Question.post_type_id
273280
end
274281

275282
private

app/views/posts/_expanded.html.erb

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,14 @@
1919
<% category ||= defined?(@category) && @category.id == post.category_id ? @category : post.category %>
2020
<% post_type ||= defined?(@post_type) && @post_type.id == post.post_type_id ? @post_type : post.post_type %>
2121

22-
<% is_question = post.post_type_id == Question.post_type_id %>
23-
<% is_top_level = post.parent_id.nil? %>
24-
25-
<div class="post <%= post.meta? ? 'is-meta' : '' %> <%= is_top_level ? '' : 'has-border-bottom-style-solid has-border-bottom-width-1 has-border-color-tertiary-100' %>"
26-
id="<%= (is_question ? 'question-' : 'answer-') + post.id.to_s %>"
22+
<div class="post <%= 'has-border-bottom-style-solid has-border-bottom-width-1 has-border-color-tertiary-100' unless post.top_level? %>"
23+
id="<%= (post.question? ? 'question-' : 'answer-') + post.id.to_s %>"
2724
data-post-id="<%= post.id %>"
2825
data-ckb-list-item data-ckb-item-type="post"
2926
data-ckb-post-id="<%= post.id %>">
30-
<% if is_top_level %>
27+
<% if post.top_level? %>
3128
<h1 class="post--title has-margin-top-0 has-padding-2">
32-
<% title = post.title +
29+
<% title = rendered_title(post) +
3330
(post.closed && !post.duplicate_post ? " [closed]" : "") +
3431
(post.duplicate_post ? " [duplicate]" : "") %>
3532
<a href="<%= generic_share_link(post) %>" class="post--title-text"><%= title %></a>
@@ -72,7 +69,7 @@
7269
<%= render('posts/notices/locked', post: post) %>
7370
<% end %>
7471

75-
<% if post.spam_flag_pending? && user_signed_in? %>
72+
<% if post.pending_spam_flag? && user_signed_in? %>
7673
<%= render('posts/notices/flagged', post: post, user: current_user) %>
7774
<% end %>
7875

@@ -82,7 +79,7 @@
8279

8380
<div class="post--body">
8481
<% effective_post = raw(sanitize(post.body, scrubber: scrubber)) %>
85-
<% if post.spam_flag_pending? && !user_signed_in? %>
82+
<% if post.pending_spam_flag? && !user_signed_in? %>
8683
<%= sanitize(effective_post, attributes: %w()) %>
8784
<% else %>
8885
<%= effective_post %>
@@ -223,7 +220,7 @@
223220
<%= render 'posts/flags_modal', post: post, user: current_user %>
224221
<% end %>
225222

226-
<% if is_top_level && post.children.undeleted.length >= SiteSetting["TableOfContentsThreshold"] && SiteSetting["TableOfContentsThreshold"] != -1 %>
223+
<% if post.top_level? && post.children.undeleted.length >= SiteSetting["TableOfContentsThreshold"] && SiteSetting["TableOfContentsThreshold"] != -1 %>
227224
<div class="toc has-margin-left-4" id="toc-toggle">
228225
<button class="toc--header"
229226
data-toggle="#toc-toggle"

app/views/posts/_list.html.erb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
<% if @show_category_tag && post.post_type.has_category %>
2727
<span class="badge is-tag is-filled"><%= post.category.name %></span>
2828
<% end %>
29-
<%= post.post_type.is_top_level ? post.title : post.parent.title %>
29+
<%= rendered_title(post) %>
3030
<%= post.post_type.is_closeable && post.closed && !post.duplicate_post ? "[closed]" : "" %>
31-
<%= post.duplicate_post && post.post_type.is_closeable && post.closed ? "[duplicate]" : "" %>
31+
<%= post.duplicate_post && post.post_type.is_closeable && post.closed ? "[duplicate]" : "" %>
3232
<% end %>
3333
</div>
3434
<% if (SiteSetting['PostBodyListTruncateLength'] || 0) > 0 %>

app/views/site_settings/index.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
</p>
2929
<h4 class="overflow-ellipsis"
3030
title="<%= setting.name %>"><%= setting.name %></h4>
31-
<div class="form-caption"><%= setting.description %></div>
31+
<div class="form-caption"><%= rendered_description(setting) %></div>
3232
</td>
3333
<% nowrap = setting.boolean? || setting.numeric? %>
3434
<td class="site-setting--value js-setting-value<%= nowrap ? ' nowrap' : ' wrap-word' %>"

db/seeds/site_settings.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -776,3 +776,19 @@
776776
community_id: ~
777777
description: >
778778
File types that users will be able to upload.
779+
780+
- name: AllowedPostTitleFormattingTypes
781+
value: code italic keyboard
782+
options:
783+
- bold
784+
- code
785+
- italic
786+
- keyboard
787+
- strikethrough
788+
- subscript
789+
- superscript
790+
value_type: array
791+
category: Display
792+
description: >
793+
Formatting types allowed in post titles.
794+
By default, only <code>code</code>, <kbd>keyboard</kbd>, and <em>italic</em> are enabled.

0 commit comments

Comments
 (0)