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> &#123;) + ) + 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.

- <%= title %> + <%= Invidious::Videos::Title.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">