mirror of
https://github.com/iv-org/invidious.git
synced 2026-10-25 14:29:16 -05:00
Make hashtags in watch-page titles clickable
Link #hashtags in the video title heading to /hashtag/..., matching description hashtag links, while keeping meta/document titles plain text.
This commit is contained in:
parent
049d591d29
commit
5d915c0098
77
spec/invidious/frontend/video_title_spec.cr
Normal file
77
spec/invidious/frontend/video_title_spec.cr
Normal file
@ -0,0 +1,77 @@
|
||||
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 < B & "C" > D'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="/hashtag/music">#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="/hashtag/music">#music</a> and <a href="/hashtag/shorts">#shorts</a>)
|
||||
)
|
||||
end
|
||||
|
||||
it "keeps punctuation outside hashtag links" do
|
||||
expect(described_class.to_html("(#music), #live!")).to eq(
|
||||
%q((<a href="/hashtag/music">#music</a>), <a href="/hashtag/live">#live</a>!)
|
||||
)
|
||||
end
|
||||
|
||||
it "supports numbers and underscores" do
|
||||
expect(described_class.to_html("#2026 #live_music")).to eq(
|
||||
%q(<a href="/hashtag/2026">#2026</a> <a href="/hashtag/live_music">#live_music</a>)
|
||||
)
|
||||
end
|
||||
|
||||
it "preserves Unicode text around and inside hashtags" do
|
||||
expect(described_class.to_html("音楽 🎵 #日本語 — #موسيقى")).to eq(
|
||||
%q(音楽 🎵 <a href="/hashtag/%E6%97%A5%E6%9C%AC%E8%AA%9E">#日本語</a> — <a href="/hashtag/%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(
|
||||
%q(<a href="/hashtag/cafe%CC%81">#café</a>)
|
||||
)
|
||||
end
|
||||
|
||||
it "preserves whitespace and stops at it" do
|
||||
expect(described_class.to_html("\t#music\n#live ")).to eq(
|
||||
"\t<a href=\"/hashtag/music\">#music</a>\n<a href=\"/hashtag/live\">#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 link query-value URL fragments" do
|
||||
title = "https://example.com?q=#section https://example.com/?next=#music"
|
||||
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(' ' '))).to eq("' &#39; &#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(<script>alert('x')</script> <a href="/hashtag/safe">#safe</a>&x="bad")
|
||||
)
|
||||
end
|
||||
end
|
||||
33
src/invidious/frontend/video_title.cr
Normal file
33
src/invidious/frontend/video_title.cr
Normal file
@ -0,0 +1,33 @@
|
||||
require "html"
|
||||
require "uri"
|
||||
|
||||
module Invidious::Frontend::VideoTitle
|
||||
extend self
|
||||
|
||||
# Match hashtags the same way description links do conceptually: a "#" that is
|
||||
# not part of a word, URL path/query fragment, or HTML entity, followed by
|
||||
# letters/digits/underscores (plus combining marks after the first character).
|
||||
HASHTAG = /(?<![\p{L}\p{M}\p{N}_&#\/=?])#[\p{L}\p{N}_][\p{L}\p{M}\p{N}_]*/
|
||||
|
||||
# Escape a video title for HTML and turn hashtags into /hashtag/... links.
|
||||
def to_html(title : String) : String
|
||||
String.build do |html|
|
||||
offset = 0
|
||||
|
||||
# Scan the original title so escaped entities (which contain '#') are not
|
||||
# treated as hashtags.
|
||||
title.scan(HASHTAG) do |match|
|
||||
html << HTML.escape(title.byte_slice(offset, match.byte_begin - offset))
|
||||
|
||||
tag = match[0].lchop('#')
|
||||
html << %(<a href="/hashtag/)
|
||||
html << URI.encode_www_form(tag, space_to_plus: false)
|
||||
html << %(">) << HTML.escape(match[0]) << "</a>"
|
||||
|
||||
offset = match.byte_end
|
||||
end
|
||||
|
||||
html << HTML.escape(title.byte_slice(offset, title.bytesize - offset))
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user