Fix clickable hashtags in watch-page titles

This commit is contained in:
Mohamed 2026-09-07 18:09:02 -04:00
parent 049d591d29
commit c250720fea
3 changed files with 98 additions and 1 deletions

View File

@ -0,0 +1,72 @@
require "../../../src/invidious/frontend/video_title"
require "spectator"
Spectator.configure do |config|
config.fail_blank
config.randomize
end
Spectator.describe Invidious::Frontend::VideoTitle do
it "preserves an empty title" do
expect(described_class.to_html("")).to eq("")
end
it "escapes a title without hashtags" do
expect(described_class.to_html(%q(A < B & "C" > D's))).to eq("A &lt; B &amp; &quot;C&quot; &gt; D&#39;s")
end
it "links a hashtag at the start of a title" do
expect(described_class.to_html("#music live")).to eq(%q(<a href="/search?q=%23music">#music</a> live))
end
it "links multiple hashtags including one at the end" do
expect(described_class.to_html("Live #music and #shorts")).to eq(
%q(Live <a href="/search?q=%23music">#music</a> and <a href="/search?q=%23shorts">#shorts</a>)
)
end
it "keeps punctuation outside hashtag links" do
expect(described_class.to_html("(#music), #live!")).to eq(
%q((<a href="/search?q=%23music">#music</a>), <a href="/search?q=%23live">#live</a>!)
)
end
it "supports numbers and underscores" do
expect(described_class.to_html("#2026 #live_music")).to eq(
%q(<a href="/search?q=%232026">#2026</a> <a href="/search?q=%23live_music">#live_music</a>)
)
end
it "preserves Unicode text around and inside hashtags" do
expect(described_class.to_html("音楽 🎵 #日本語 — #موسيقى")).to eq(
%q(音楽 🎵 <a href="/search?q=%23%E6%97%A5%E6%9C%AC%E8%AA%9E">#日本語</a> — <a href="/search?q=%23%D9%85%D9%88%D8%B3%D9%8A%D9%82%D9%89">#موسيقى</a>)
)
end
it "includes combining marks without normalizing the query" do
expect(described_class.to_html("#cafe\u0301")).to eq(
"<a href=\"/search?q=%23cafe%CC%81\">#cafe\u0301</a>"
)
end
it "preserves whitespace and stops at it" do
expect(described_class.to_html("\t#music\n#live ")).to eq(
"\t<a href=\"/search?q=%23music\">#music</a>\n<a href=\"/search?q=%23live\">#live</a> "
)
end
it "does not link bare hashes, embedded hashes or URL fragments" do
title = "# ## C# F#lang word#tag https://example.com/#section https://example.com#other"
expect(described_class.to_html(title)).to eq(title)
end
it "does not turn literal or generated HTML entities into links" do
expect(described_class.to_html(%q(' &#39; &#x27;))).to eq("&#39; &amp;#39; &amp;#x27;")
end
it "escapes markup and query delimiters next to hashtags" do
expect(described_class.to_html(%q(<script>alert('x')</script> #safe&x="bad"))).to eq(
%q(&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt; <a href="/search?q=%23safe">#safe</a>&amp;x=&quot;bad&quot;)
)
end
end

View File

@ -0,0 +1,25 @@
require "html"
require "uri"
module Invidious::Frontend::VideoTitle
extend self
# Keep word-internal hashes, URL fragments and HTML entities as plain text.
HASHTAG = /(?<![\p{L}\p{M}\p{N}_&#\/])#[\p{L}\p{N}_][\p{L}\p{M}\p{N}_]*/
def to_html(title : String) : String
String.build do |html|
offset = 0
# Match the original text, not escaped entities (which may contain '#').
title.scan(HASHTAG) do |match|
html << HTML.escape(title.byte_slice(offset, match.byte_begin - offset))
html << "<a href=\"/search?q=" << URI.encode_www_form(match[0]) << "\">"
html << HTML.escape(match[0]) << "</a>"
offset = match.byte_end
end
html << HTML.escape(title.byte_slice(offset, title.bytesize - offset))
end
end
end

View File

@ -93,7 +93,7 @@ we're going to need to do it here in order to allow for translations.
<div class="h-box">
<h1>
<%= title %>
<%= Invidious::Frontend::VideoTitle.to_html(video.title) %>
<% if params.listen %>
<a title="<%=I18n.translate(locale, "Video mode")%>" id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=0" href="/watch?<%= env.params.query %>&listen=0">
<i class="icon ion-ios-videocam"></i>