From ae946c1cb515d9052ed2cc5b54dd2b894e9a86a5 Mon Sep 17 00:00:00 2001 From: snow Date: Tue, 8 Sep 2026 03:19:49 +0530 Subject: [PATCH] Add clickable hashtags to video titles --- spec/invidious/frontend/video_title_spec.cr | 73 +++++++++++++++++++++ src/invidious/frontend/video_title.cr | 28 ++++++++ src/invidious/views/watch.ecr | 2 +- 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 spec/invidious/frontend/video_title_spec.cr create mode 100644 src/invidious/frontend/video_title.cr diff --git a/spec/invidious/frontend/video_title_spec.cr b/spec/invidious/frontend/video_title_spec.cr new file mode 100644 index 000000000..17074fd3f --- /dev/null +++ b/spec/invidious/frontend/video_title_spec.cr @@ -0,0 +1,73 @@ +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 "links hashtags at the start and end of a title" do + expect(described_class.to_html("#music live #concert")).to eq( + %(#music live #concert) + ) + end + + it "keeps surrounding punctuation outside links" do + expect(described_class.to_html("(#one), [#two]! {#three}. #four-five")).to eq( + %((#one), [#two]! {#three}. #four-five) + ) + end + + it "preserves case, numbers and underscores" do + expect(described_class.to_html("#Open_Source2026 #123")).to eq( + %(#Open_Source2026 #123) + ) + end + + it "supports Unicode letters and combining marks with encoded paths" do + expect(described_class.to_html("🎵 #日本語 #cafe\u0301 #हिन्दी")).to eq( + %(🎵 #日本語 #cafe\u0301 #हिन्दी) + ) + end + + it "preserves whitespace between consecutive hashtags" do + expect(described_class.to_html("#one\t#two\n#three")).to eq( + %(#one\t#two\n#three) + ) + end + + it "recognizes quoted hashtags and Unicode whitespace" do + expect(described_class.to_html(%('#one' "#two"\u00a0#three))).to eq( + %('#one' "#two"\u00a0#three) + ) + end + + it "does not link word suffixes, URL fragments or bare hashes" do + title = "C# C#Sharp word#tag https://example.com/#fragment https://example.com?q=#section # ##tag #!" + expect(described_class.to_html(title)).to eq(title) + end + + it "escapes markup and quotes while preserving the displayed title" do + expect(described_class.to_html(%( #safe & 'quoted'))).to eq( + %(<script>alert("x")</script> #safe & 'quoted') + ) + end + + it "does not turn escaped character references into hashtags" do + expect(described_class.to_html(%(' " { & #ok))).to eq( + %(' " &#123; &amp; #ok) + ) + end + + it "escapes attempted attribute injection after a hashtag" do + expect(described_class.to_html(%(#tag" onclick="alert(1)))).to eq( + %(#tag" onclick="alert(1)) + ) + end + + it "handles empty titles and titles without hashtags" do + expect(described_class.to_html("")).to eq("") + expect(described_class.to_html("Music & video")).to eq("Music & video") + end +end diff --git a/src/invidious/frontend/video_title.cr b/src/invidious/frontend/video_title.cr new file mode 100644 index 000000000..26c934256 --- /dev/null +++ b/src/invidious/frontend/video_title.cr @@ -0,0 +1,28 @@ +require "html" +require "uri" + +module Invidious::Frontend::VideoTitle + extend self + + # Keep word-internal hashes, URL fragments and HTML entities as plain text. + HASHTAG = /(^|[\s\p{Z}(\[{"'])#([\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(0) - offset)) + html << HTML.escape(match[1]) + + tag = match[2] + html << %(#) + html << HTML.escape(tag) << "" + offset = match.byte_end(0) + end + + html << HTML.escape(title.byte_slice(offset, title.bytesize - offset)) + end + end +end diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 3dc19c9e2..3f531aa34 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -93,7 +93,7 @@ we're going to need to do it here in order to allow for translations.

- <%= title %> + <%= Invidious::Frontend::VideoTitle.to_html(video.title) %> <% if params.listen %> " id="link-iv-listen" data-base-url="/watch?<%= env.params.query %>&listen=0" href="/watch?<%= env.params.query %>&listen=0">