From 63df050121bec8ca2001ecaac350ff3be8b0eb82 Mon Sep 17 00:00:00 2001 From: Steven Chang Date: Mon, 21 Sep 2026 23:25:49 +0000 Subject: [PATCH] DEV: refactor ascii slug generation --- .../slug_ascii_patch.rb | 9 +++ lib/discourse_modifications/topic_slug.rb | 22 ------- plugin.rb | 20 +++--- .../slug_patch_spec.rb | 30 +++++++++ .../topic_slug_spec.rb | 62 ------------------- 5 files changed, 48 insertions(+), 95 deletions(-) create mode 100644 lib/discourse_modifications/slug_ascii_patch.rb delete mode 100644 lib/discourse_modifications/topic_slug.rb create mode 100644 spec/lib/discourse_modifications/slug_patch_spec.rb delete mode 100644 spec/lib/discourse_modifications/topic_slug_spec.rb diff --git a/lib/discourse_modifications/slug_ascii_patch.rb b/lib/discourse_modifications/slug_ascii_patch.rb new file mode 100644 index 0000000..d65add1 --- /dev/null +++ b/lib/discourse_modifications/slug_ascii_patch.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ::DiscourseModifications + module SlugAsciiPatch + def ascii_generator(string) + super(AnyAscii.transliterate(string.to_s)) + end + end +end diff --git a/lib/discourse_modifications/topic_slug.rb b/lib/discourse_modifications/topic_slug.rb deleted file mode 100644 index 47f5663..0000000 --- a/lib/discourse_modifications/topic_slug.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -require 'any_ascii' - -module ::DiscourseModifications - class TopicSlug - - # Converts a topic title into a slug, removing any emoji strings and normalizing the text. - # This is primarily adds the unicode normalization step to the existing slug generation process - # for ASCII encoding. - def self.slug_for_topic(topic, slug, title) - return slug unless (SiteSetting.slug_generation_method || :ascii).to_sym == :ascii - - string = title.gsub(/:([\w\-+]+(?::t\d)?):/, "") - string = AnyAscii.transliterate(string) - string = Slug.ascii_generator(string) - string = Slug.prettify_slug(string, max_length: Slug::MAX_LENGTH) - - string.blank? || Slug.slug_is_only_numbers?(string) ? "topic-#{topic.id}" : string - end - end -end \ No newline at end of file diff --git a/plugin.rb b/plugin.rb index 141d822..875b0ee 100644 --- a/plugin.rb +++ b/plugin.rb @@ -8,7 +8,7 @@ # url: TODO # required_version: 2.7.0 -gem 'any_ascii', '0.3.2' +gem "any_ascii", "0.3.2" enabled_site_setting :discourse_modifications_enabled @@ -21,19 +21,17 @@ module ::DiscourseModifications require_relative "lib/discourse_modifications/engine" after_initialize do - # Code which should run after Rails has finished booting - - # Note: if this file gets too large, consider moving code into separate files in the lib directory - # and applying a Initializer pattern to load them. - - Topic.slug_computed_callbacks << ::DiscourseModifications::TopicSlug.method(:slug_for_topic) + # Patch Slug.ascii_generator to use AnyAscii before parameterize so that + # Unicode characters (including mathematical/stylised variants) transliterate + # correctly everywhere Slug.for is called — AR lifecycle hooks, bulk import, + # category slugs, etc. + Slug.singleton_class.prepend(::DiscourseModifications::SlugAsciiPatch) # add permalink normalization normalizations = SiteSetting.permalink_normalizations normalizations = normalizations.blank? ? [] : normalizations.split("|") - - normalizations << ::DiscourseModifications::XF_TOPIC_LINK_NORMALIZATION if normalizations.exclude?(::DiscourseModifications::XF_TOPIC_LINK_NORMALIZATION) - + if normalizations.exclude?(::DiscourseModifications::XF_TOPIC_LINK_NORMALIZATION) + normalizations << ::DiscourseModifications::XF_TOPIC_LINK_NORMALIZATION + end SiteSetting.permalink_normalizations = normalizations.join("|") - end diff --git a/spec/lib/discourse_modifications/slug_patch_spec.rb b/spec/lib/discourse_modifications/slug_patch_spec.rb new file mode 100644 index 0000000..fd16712 --- /dev/null +++ b/spec/lib/discourse_modifications/slug_patch_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe "Slug.ascii_generator AnyAscii patch" do + before do + allow(SiteSetting).to receive(:slug_generation_method).and_return("ascii") + allow(SiteSetting).to receive(:default_locale).and_return("en") + end + + it "transliterates mathematical bold/italic Unicode to ASCII" do + title = "𝑻𝒉𝒆 𝒔𝒖𝒎𝒎𝒆𝒓 𝒘𝒆 𝒇𝒆𝒍𝒍 𝒊𝒏 𝒍𝒐𝒗𝒆 [𝐇𝐀𝐈𝐊𝐘𝐔]" + expect(Slug.for(title)).to eq("the-summer-we-fell-in-love-haikyu") + end + + it "still handles plain ASCII titles" do + expect(Slug.for("Hello World")).to eq("hello-world") + end + + it "still handles accented characters" do + expect(Slug.for("Café au lait")).to eq("cafe-au-lait") + end + + it "falls back to the default when slug is blank after transliteration" do + expect(Slug.for(":smile:")).to eq("topic") + end + + it "is not applied when slug_generation_method is encoded" do + allow(SiteSetting).to receive(:slug_generation_method).and_return("encoded") + expect(Slug.for("Héllo")).to eq("h%C3%A9llo") + end +end diff --git a/spec/lib/discourse_modifications/topic_slug_spec.rb b/spec/lib/discourse_modifications/topic_slug_spec.rb deleted file mode 100644 index ea5dcdd..0000000 --- a/spec/lib/discourse_modifications/topic_slug_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -RSpec.configure { |c| c.filter_run_when_matching :focus } - -RSpec.describe DiscourseModifications::TopicSlug do - let(:topic) { Fabricate(:topic, id: 42) } - - describe ".slug_for_topic" do - before do - # Default to ascii slug generation - allow(SiteSetting).to receive(:slug_generation_method).and_return("ascii") - end - - it "removes emoji codes from the title" do - title = "Hello :smile: World" - slug = "hello-world" - expect(described_class.slug_for_topic(topic, slug, title)).to eq("hello-world") - end - - it "unicode normalizes the title" do - title = "Café" - slug = "cafe" - expect(described_class.slug_for_topic(topic, slug, title)).to eq("cafe") - end - - it "returns the original slug if slug_generation_method is not ascii" do - allow(SiteSetting).to receive(:slug_generation_method).and_return("encoded") - expect(described_class.slug_for_topic(topic, "original-slug", "Some Title")).to eq("original-slug") - end - - it "returns a fallback slug if the result is blank" do - title = ":smile:" - slug = "" - expect(described_class.slug_for_topic(topic, slug, title)).to eq("topic-42") - end - - it "returns a fallback slug if the result is only numbers" do - title = "123456" - slug = "123456" - expect(described_class.slug_for_topic(topic, slug, title)).to eq("topic-42") - end - - it "truncates the slug to the max length" do - long_title = "a" * (Slug::MAX_LENGTH + 10) - slug = "a" * (Slug::MAX_LENGTH + 10) - result = described_class.slug_for_topic(topic, slug, long_title) - expect(result.length).to eq(Slug::MAX_LENGTH) - end - - it "handles titles with multiple emoji codes" do - title = "Hello :smile: World :rocket:" - slug = "hello-world" - expect(described_class.slug_for_topic(topic, slug, title)).to eq("hello-world") - end - - it "handles titles with emoji codes with t modifier" do - title = "Hello :wave:t2: World" - slug = "hello-world" - expect(described_class.slug_for_topic(topic, slug, title)).to eq("hello-world") - end - end -end \ No newline at end of file