From c3fe2c94b03d780bfa1a0c591f078237344b39ba Mon Sep 17 00:00:00 2001
From: privacyguy123 <50337995+privacyguy123@users.noreply.github.com>
Date: Sat, 19 Sep 2026 19:42:49 +0100
Subject: [PATCH] Link hashtags in the visible watch title
---
spec/invidious/videos/title_spec.cr | 50 +++++++++++++++++++++++++++++
src/invidious/videos/title.cr | 18 +++++++++++
src/invidious/views/watch.ecr | 2 +-
3 files changed, 69 insertions(+), 1 deletion(-)
create mode 100644 spec/invidious/videos/title_spec.cr
create mode 100644 src/invidious/videos/title.cr
diff --git a/spec/invidious/videos/title_spec.cr b/spec/invidious/videos/title_spec.cr
new file mode 100644
index 000000000..10d829d64
--- /dev/null
+++ b/spec/invidious/videos/title_spec.cr
@@ -0,0 +1,50 @@
+require "spectator"
+require "../../../src/invidious/videos/title"
+
+Spectator.describe Invidious::Videos::Title do
+ it "links hashtags at the start, middle and end of a title" do
+ expect(described_class.to_html("#First with #TwoWords and #last")).to eq(
+ %(#First with #TwoWords and #last)
+ )
+ end
+
+ it "keeps punctuation outside hashtag links" do
+ expect(described_class.to_html(%((#one), [#two]! "#three"))).to eq(
+ %((#one), [#two]! "#three")
+ )
+ end
+
+ it "preserves Unicode letters, combining marks, numbers and underscores" do
+ expect(described_class.to_html("🎵 #cafe\u0301 #東京 #हिन्दी #2026_live")).to eq(
+ %(🎵 #cafe\u0301 #東京 #हिन्दी #2026_live)
+ )
+ end
+
+ it "recognizes tabs, newlines and non-breaking spaces as boundaries" do
+ expect(described_class.to_html("a\t#one\n#two\u00a0#three")).to eq(
+ %(a\t#one\n#two\u00a0#three)
+ )
+ end
+
+ it "recognizes Unicode opening brackets and quotation marks" do
+ expect(described_class.to_html("「#東京」 ‘#music’")).to eq(
+ %(「#東京」 ‘#music’)
+ )
+ end
+
+ it "does not link fragments, suffixes, bare hashes or adjacent hashes" do
+ title = "https://example.com/#fragment file#part C# ##tag # #\u0301"
+ expect(described_class.to_html(title)).to eq(HTML.escape(title))
+ end
+
+ it "escapes markup and entities without creating extra elements" do
+ expect(described_class.to_html(%( & #safe {))).to eq(
+ %(<script>alert("x")</script> & #safe <img src=x> {)
+ )
+ end
+
+ it "preserves empty and ordinary titles" do
+ expect(described_class.to_html("")).to eq("")
+ expect(described_class.to_html("Rock & roll")).to eq("Rock & roll")
+ end
+end
diff --git a/src/invidious/videos/title.cr b/src/invidious/videos/title.cr
new file mode 100644
index 000000000..c91889e5e
--- /dev/null
+++ b/src/invidious/videos/title.cr
@@ -0,0 +1,18 @@
+require "html"
+require "uri"
+
+module Invidious::Videos::Title
+ def self.to_html(title : String) : String
+ String.build do |html|
+ offset = 0
+ title.scan(/(?:\A|[\s\p{Z}\p{Ps}\p{Pi}"'])#([\p{L}\p{N}_][\p{L}\p{M}\p{N}_]*)/) do |match|
+ start = match.byte_begin(1) - 1
+ html << HTML.escape(title.byte_slice(offset, start - offset))
+ html << %()
+ html << HTML.escape("##{match[1]}") << ""
+ offset = match.byte_end(1)
+ end
+ html << HTML.escape(title.byte_slice(offset))
+ end
+ end
+end
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr
index 3dc19c9e2..081f9c4b1 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.