Render inline code in card titles (#2518)

* Remove unused marked JS dependency

* Remove unused redcarpet dependency

* Render inline code in card titles

Add card_html_title helper that HTML-escapes input then converts
backtick-wrapped text to <code> elements. Apply to card titles in
board preview, card detail, public views, and notification emails.
Style inline code elements in titles to match description styling.

Co-authored-by: Andy Smith <andy@37signals.com>

---------

Co-authored-by: Andy Smith <andy@37signals.com>
This commit is contained in:
Mike Dalessio
2026-02-12 12:07:40 -05:00
committed by GitHub
parent 486da76a26
commit 851f13a934
15 changed files with 63 additions and 2597 deletions
-1
View File
@@ -25,7 +25,6 @@ gem "trilogy", "~> 2.9"
gem "bcrypt", "~> 3.1.7" gem "bcrypt", "~> 3.1.7"
gem "geared_pagination", "~> 1.2" gem "geared_pagination", "~> 1.2"
gem "rqrcode" gem "rqrcode"
gem "redcarpet"
gem "rouge" gem "rouge"
gem "jbuilder" gem "jbuilder"
gem "lexxy", bc: "lexxy" gem "lexxy", bc: "lexxy"
-2
View File
@@ -355,7 +355,6 @@ GEM
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
redcarpet (3.6.1)
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.3) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
@@ -521,7 +520,6 @@ DEPENDENCIES
puma (>= 5.0) puma (>= 5.0)
rack-mini-profiler rack-mini-profiler
rails! rails!
redcarpet
rouge rouge
rqrcode rqrcode
rubocop-rails-omakase rubocop-rails-omakase
-2
View File
@@ -466,7 +466,6 @@ GEM
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
redcarpet (3.6.1)
regexp_parser (2.11.3) regexp_parser (2.11.3)
reline (0.6.3) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
@@ -676,7 +675,6 @@ DEPENDENCIES
rack-mini-profiler rack-mini-profiler
rails! rails!
rails_structured_logging! rails_structured_logging!
redcarpet
rouge rouge
rqrcode rqrcode
rubocop-rails-omakase rubocop-rails-omakase
+9
View File
@@ -220,6 +220,15 @@
.card__title-link { .card__title-link {
color: inherit; color: inherit;
} }
code {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.25ch;
font-family: var(--font-mono);
font-size: smaller;
padding: 0.1ch 0.25ch;
}
} }
.card__description { .card__description {
+6
View File
@@ -2,4 +2,10 @@ module HtmlHelper
def format_html(html) def format_html(html)
Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe Loofah::HTML5::DocumentFragment.parse(html).scrub!(AutoLinkScrubber.new).to_html.html_safe
end end
def card_html_title(card)
return card.title if card.title.blank?
ERB::Util.html_escape(card.title).gsub(/`([^`]+)`/, '<code>\1</code>').html_safe
end
end end
@@ -1,5 +1,5 @@
<h1 class="card__title flex align-start gap-half"> <h1 class="card__title flex align-start gap-half">
<%= link_to card.title, edit_card_path(card), class: "card__title-link" %> <%= link_to card_html_title(card), edit_card_path(card), class: "card__title-link" %>
</h1> </h1>
<% unless card.description.blank? %> <% unless card.description.blank? %>
+1 -1
View File
@@ -36,7 +36,7 @@
<div class="card__body justify-space-between"> <div class="card__body justify-space-between">
<div class="card__content"> <div class="card__content">
<h3 class="card__title overflow-line-clamp"> <h3 class="card__title overflow-line-clamp">
<%= card.title %> <%= card_html_title(card) %>
</h3> </h3>
</div> </div>
@@ -17,7 +17,7 @@
<div class="card__body justify-space-between"> <div class="card__body justify-space-between">
<div class="card__content"> <div class="card__content">
<h1 class="card__title overflow-line-clamp"> <h1 class="card__title overflow-line-clamp">
<%= card.title %> <%= card_html_title(card) %>
</h1> </h1>
</div> </div>
@@ -9,7 +9,7 @@
<hr class="notification__board-separator"> <hr class="notification__board-separator">
<h2 class="notification__board"><%= link_to board.name, board %></h2> <h2 class="notification__board"><%= link_to board.name, board %></h2>
<% board_notifications.group_by(&:card).each do |card, notifications| %> <% board_notifications.group_by(&:card).each do |card, notifications| %>
<%= link_to "##{ card.number } #{ card.title }", card, class: "card__title" %> <%= link_to card, class: "card__title" do %>#<%= card.number %> <%= card_html_title(card) %><% end %>
<%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %> <%= render partial: "notification/bundle_mailer/notification", collection: notifications, as: :notification %>
<% end %> <% end %>
<% end %> <% end %>
@@ -1,6 +1,6 @@
<div class="card__content"> <div class="card__content">
<h1 class="card__title flex align-start gap-half"> <h1 class="card__title flex align-start gap-half">
<%= tag.span card.title, class: "card__title-link" %> <%= tag.span card_html_title(card), class: "card__title-link" %>
</h1> </h1>
<div class="card__description lexxy-content" data-controller="syntax-highlight"> <div class="card__description lexxy-content" data-controller="syntax-highlight">
<%= card.description %> <%= card.description %>
-1
View File
@@ -13,7 +13,6 @@ pin_all_from "app/javascript/initializers", under: "initializers"
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers" pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers" pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge" pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge"
pin "marked" # @15.0.11
pin "lexxy" pin "lexxy"
pin "@rails/activestorage", to: "activestorage.esm.js" pin "@rails/activestorage", to: "activestorage.esm.js"
pin "@rails/actiontext", to: "actiontext.esm.js" pin "@rails/actiontext", to: "actiontext.esm.js"
+8 -3
View File
@@ -42,9 +42,14 @@ class CardsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to card_draft_path(card) assert_redirected_to card_draft_path(card)
end end
test "show" do test "show renders inline code in title" do
get card_path(cards(:logo)) card = cards(:logo)
assert_response :success card.update_column :title, "Fix the `bug` in production"
get card_path(card)
assert_select ".card__title-link" do |element|
assert_equal "Fix the <code>bug</code> in production", element.inner_html
end
end end
test "edit" do test "edit" do
+25
View File
@@ -119,4 +119,29 @@ class HtmlHelperTest < ActionView::TestCase
assert_no_match(/<img/, output, "should not create an img element") assert_no_match(/<img/, output, "should not create an img element")
assert_includes output, "&lt;img" assert_includes output, "&lt;img"
end end
test "card_html_title renders backticks as code elements" do
assert_equal "Fix the <code>bug</code> in production", card_html_title(cards(:logo).tap { _1.title = "Fix the `bug` in production" })
end
test "card_html_title renders multiple code spans" do
assert_equal "<code>foo</code> and <code>bar</code>", card_html_title(cards(:logo).tap { _1.title = "`foo` and `bar`" })
end
test "card_html_title renders code spans without surrounding spaces" do
assert_equal "what<code>about</code>this", card_html_title(cards(:logo).tap { _1.title = "what`about`this" })
end
test "card_html_title escapes HTML tags" do
assert_equal "&lt;script&gt;alert(1)&lt;/script&gt;", card_html_title(cards(:logo).tap { _1.title = "<script>alert(1)</script>" })
end
test "card_html_title escapes HTML inside backticks" do
assert_equal "<code>&lt;script&gt;</code>", card_html_title(cards(:logo).tap { _1.title = "`<script>`" })
end
test "card_html_title returns blank title as-is" do
assert_nil card_html_title(cards(:logo).tap { _1.title = nil })
assert_equal "", card_html_title(cards(:logo).tap { _1.title = "" })
end
end end
@@ -87,6 +87,16 @@ class Notification::BundleMailerTest < ActionMailer::TestCase
assert_includes card_titles, "#2 Layout is broken" assert_includes card_titles, "#2 Layout is broken"
end end
test "renders inline code in card title" do
cards(:logo).update_column :title, "Fix the `bug` in production"
create_notification(@user, source: events(:logo_published))
html = Nokogiri::HTML5(Notification::BundleMailer.notification(@bundle).html_part.body.to_s)
title_link = html.at_css(".card__title")
assert_equal "#1 Fix the <code>bug</code> in production", title_link.inner_html
end
private private
def create_notification(user, source: events(:logo_published)) def create_notification(user, source: events(:logo_published))
Notification.create!(user: user, creator: user, source: source, created_at: 30.minutes.ago) Notification.create!(user: user, creator: user, source: source, created_at: 30.minutes.ago)
-2583
View File
File diff suppressed because it is too large Load Diff