From 768b00c4d0c05c35c2c6c9bc8b4a821f1bde119d Mon Sep 17 00:00:00 2001
From: Jed Fox <git@jedfox.com>
Date: Fri, 2 Jun 2023 13:58:18 -0400
Subject: [PATCH] =?UTF-8?q?Consistently=20use=20middle=20dot=20(=C2=B7)=20?=
 =?UTF-8?q?instead=20of=20bullet=20(=E2=80=A2)=20to=20separate=20items=20(?=
 =?UTF-8?q?#25248)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .eslintrc.js                                  |  9 ++++++
 .haml-lint.yml                                |  5 +++
 .rubocop.yml                                  |  4 +++
 .../_email_domain_block.html.haml             |  2 +-
 .../_domain_block.html.haml                   |  6 ++--
 app/views/admin/instances/_instance.html.haml |  2 +-
 app/views/admin/instances/show.html.haml      |  2 +-
 app/views/admin/ip_blocks/_ip_block.html.haml |  2 +-
 app/views/admin/roles/_role.html.haml         |  2 +-
 .../trends/links/_preview_card.html.haml      | 10 +++---
 .../admin/trends/statuses/_status.html.haml   | 10 +++---
 app/views/admin/trends/tags/_tag.html.haml    |  6 ++--
 app/views/admin/webhooks/_webhook.html.haml   |  2 +-
 .../admin_mailer/_new_trending_links.text.erb |  4 +--
 .../_new_trending_statuses.text.erb           |  2 +-
 .../admin_mailer/_new_trending_tags.text.erb  |  2 +-
 .../authorized_applications/index.html.haml   |  2 +-
 lib/linter/haml_middle_dot.rb                 | 26 ++++++++++++++++
 lib/linter/rubocop_middle_dot.rb              | 31 +++++++++++++++++++
 19 files changed, 102 insertions(+), 27 deletions(-)
 create mode 100644 lib/linter/haml_middle_dot.rb
 create mode 100644 lib/linter/rubocop_middle_dot.rb

diff --git a/.eslintrc.js b/.eslintrc.js
index 24961cdd9..91dcd8e60 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -81,6 +81,15 @@ module.exports = {
       { property: 'substring', message: 'Use .slice instead of .substring.' },
       { property: 'substr', message: 'Use .slice instead of .substr.' },
     ],
+    'no-restricted-syntax': [
+      'error',
+      {
+        // eslint-disable-next-line no-restricted-syntax
+        selector: 'Literal[value=/•/], JSXText[value=/•/]',
+        // eslint-disable-next-line no-restricted-syntax
+        message: "Use '·' (middle dot) instead of '•' (bullet)",
+      },
+    ],
     'no-self-assign': 'off',
     'no-unused-expressions': 'error',
     'no-unused-vars': 'off',
diff --git a/.haml-lint.yml b/.haml-lint.yml
index 12ca46342..d1ed30b26 100644
--- a/.haml-lint.yml
+++ b/.haml-lint.yml
@@ -4,6 +4,11 @@ exclude:
   - 'vendor/**/*'
   - lib/templates/haml/scaffold/_form.html.haml
 
+require:
+  - ./lib/linter/haml_middle_dot.rb
+
 linters:
   AltText:
     enabled: true
+  MiddleDot:
+    enabled: true
diff --git a/.rubocop.yml b/.rubocop.yml
index bd561df1d..eff89bdae 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -11,6 +11,7 @@ require:
   - rubocop-rspec
   - rubocop-performance
   - rubocop-capybara
+  - ./lib/linter/rubocop_middle_dot
 
 AllCops:
   TargetRubyVersion: 3.0 # Set to minimum supported version of CI
@@ -205,3 +206,6 @@ Style/TrailingCommaInArrayLiteral:
 # https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral
 Style/TrailingCommaInHashLiteral:
   EnforcedStyleForMultiline: 'comma'
+
+Style/MiddleDot:
+  Enabled: true
diff --git a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
index c5a55bc27..7cb973c4b 100644
--- a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
+++ b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml
@@ -9,6 +9,6 @@
 
       - if email_domain_block.parent.present?
         = t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain))
-        •
+        ·
 
       = t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts })
diff --git a/app/views/admin/export_domain_blocks/_domain_block.html.haml b/app/views/admin/export_domain_blocks/_domain_block.html.haml
index 5d4b6c4d0..cdce4fd28 100644
--- a/app/views/admin/export_domain_blocks/_domain_block.html.haml
+++ b/app/views/admin/export_domain_blocks/_domain_block.html.haml
@@ -17,11 +17,11 @@
 
       %br/
 
-      = f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
+      = f.object.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
       - if f.object.public_comment.present?
-        •
+        ·
         = f.object.public_comment
       - if existing_relationships
-        •
+        ·
         = fa_icon 'warning fw'
         = t('admin.export_domain_blocks.import.existing_relationships_warning')
diff --git a/app/views/admin/instances/_instance.html.haml b/app/views/admin/instances/_instance.html.haml
index 93f9bd418..65cf789ce 100644
--- a/app/views/admin/instances/_instance.html.haml
+++ b/app/views/admin/instances/_instance.html.haml
@@ -6,7 +6,7 @@
 
       %small
         - if instance.domain_block
-          = instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
+          = instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
         - elsif instance.domain_allow
           = t('admin.accounts.whitelisted')
         - else
diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml
index ab290912e..6d67d389d 100644
--- a/app/views/admin/instances/show.html.haml
+++ b/app/views/admin/instances/show.html.haml
@@ -58,7 +58,7 @@
             %td= @instance.domain_block.public_comment
           %tr
             %th= t('admin.instances.content_policies.policy')
-            %td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' • ')
+            %td= @instance.domain_block.policies.map { |policy| t(policy, scope: 'admin.instances.content_policies.policies') }.join(' · ')
 
     = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@instance.domain_block), class: 'button'
     = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@instance.domain_block), class: 'button', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete }
diff --git a/app/views/admin/ip_blocks/_ip_block.html.haml b/app/views/admin/ip_blocks/_ip_block.html.haml
index b8d3ac0e8..3dc6f8f8e 100644
--- a/app/views/admin/ip_blocks/_ip_block.html.haml
+++ b/app/views/admin/ip_blocks/_ip_block.html.haml
@@ -5,7 +5,7 @@
     .pending-account__header
       %samp= link_to "#{ip_block.ip}/#{ip_block.ip.prefix}", admin_accounts_path(ip: "#{ip_block.ip}/#{ip_block.ip.prefix}")
       - if ip_block.comment.present?
-        •
+        ·
         = ip_block.comment
       %br/
       = t("simple_form.labels.ip_block.severities.#{ip_block.severity}")
diff --git a/app/views/admin/roles/_role.html.haml b/app/views/admin/roles/_role.html.haml
index 798d8d8b4..d6c6b62c8 100644
--- a/app/views/admin/roles/_role.html.haml
+++ b/app/views/admin/roles/_role.html.haml
@@ -24,7 +24,7 @@
         = t('admin.roles.everyone_full_description_html')
       - else
         = link_to t('admin.roles.assigned_users', count: role.users.count), admin_accounts_path(role_ids: role.id)
-        •
+        ·
         %abbr{ title: role.permissions_as_keys.map { |privilege| I18n.t("admin.roles.privileges.#{privilege}") }.join(', ') }= t('admin.roles.permissions_count', count: role.permissions_as_keys.size)
     %div
       = table_link_to 'pencil', t('admin.accounts.edit'), edit_admin_role_path(role) if can?(:update, role)
diff --git a/app/views/admin/trends/links/_preview_card.html.haml b/app/views/admin/trends/links/_preview_card.html.haml
index 8812feb31..1ca348371 100644
--- a/app/views/admin/trends/links/_preview_card.html.haml
+++ b/app/views/admin/trends/links/_preview_card.html.haml
@@ -10,21 +10,21 @@
 
       - if preview_card.provider_name.present?
         = preview_card.provider_name
-        •
+        ·
 
       - if preview_card.language.present?
         = standard_locale_name(preview_card.language)
-        •
+        ·
 
       = t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })
 
       - if preview_card.trend.allowed?
-        •
+        ·
         %abbr{ title: t('admin.trends.tags.current_score', score: preview_card.trend.score) }= t('admin.trends.tags.trending_rank', rank: preview_card.trend.rank)
 
         - if preview_card.decaying?
-          •
+          ·
           = t('admin.trends.tags.peaked_on_and_decaying', date: l(preview_card.max_score_at.to_date, format: :short))
       - elsif preview_card.requires_review?
-        •
+        ·
         = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/statuses/_status.html.haml b/app/views/admin/trends/statuses/_status.html.haml
index f35e13d12..98f2e7709 100644
--- a/app/views/admin/trends/statuses/_status.html.haml
+++ b/app/views/admin/trends/statuses/_status.html.haml
@@ -17,17 +17,17 @@
     = t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count))
 
     - if status.account.domain.present?
-      •
+      ·
       = status.account.domain
     - if status.language.present?
-      •
+      ·
       = standard_locale_name(status.language)
     - if status.trendable? && !status.account.discoverable?
-      •
+      ·
       = t('admin.trends.statuses.not_discoverable')
     - if status.trend.allowed?
-      •
+      ·
       %abbr{ title: t('admin.trends.tags.current_score', score: status.trend.score) }= t('admin.trends.tags.trending_rank', rank: status.trend.rank)
     - elsif status.requires_review?
-      •
+      ·
       = t('admin.trends.pending_review')
diff --git a/app/views/admin/trends/tags/_tag.html.haml b/app/views/admin/trends/tags/_tag.html.haml
index a30666a08..3bbdd08db 100644
--- a/app/views/admin/trends/tags/_tag.html.haml
+++ b/app/views/admin/trends/tags/_tag.html.haml
@@ -13,12 +13,12 @@
       = t('admin.trends.tags.used_by_over_week', count: tag.history.reduce(0) { |sum, day| sum + day.accounts })
 
       - if tag.trendable? && (rank = Trends.tags.rank(tag.id))
-        •
+        ·
         %abbr{ title: t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
 
         - if tag.decaying?
-          •
+          ·
           = t('admin.trends.tags.peaked_on_and_decaying', date: l(tag.max_score_at.to_date, format: :short))
       - elsif tag.requires_review?
-        •
+        ·
         = t('admin.trends.pending_review')
diff --git a/app/views/admin/webhooks/_webhook.html.haml b/app/views/admin/webhooks/_webhook.html.haml
index d94a41eb3..6b3e49eba 100644
--- a/app/views/admin/webhooks/_webhook.html.haml
+++ b/app/views/admin/webhooks/_webhook.html.haml
@@ -10,7 +10,7 @@
       - else
         %span.negative-hint= t('admin.webhooks.disabled')
 
-      •
+      ·
 
       %abbr{ title: webhook.events.join(', ') }= t('admin.webhooks.enabled_events', count: webhook.events.size)
 
diff --git a/app/views/admin_mailer/_new_trending_links.text.erb b/app/views/admin_mailer/_new_trending_links.text.erb
index 602e12793..85f3f8039 100644
--- a/app/views/admin_mailer/_new_trending_links.text.erb
+++ b/app/views/admin_mailer/_new_trending_links.text.erb
@@ -1,8 +1,8 @@
 <%= raw t('admin_mailer.new_trends.new_trending_links.title') %>
 
 <% @links.each do |link| %>
-- <%= link.title %> • <%= link.url %>
-  <%= standard_locale_name(link.language) %> • <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %>
+- <%= link.title %> · <%= link.url %>
+  <%= standard_locale_name(link.language) %> · <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> · <%= t('admin.trends.tags.current_score', score: link.trend.score.round(2)) %>
 <% end %>
 
 <%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
diff --git a/app/views/admin_mailer/_new_trending_statuses.text.erb b/app/views/admin_mailer/_new_trending_statuses.text.erb
index 1ed3ae857..eedbfff9d 100644
--- a/app/views/admin_mailer/_new_trending_statuses.text.erb
+++ b/app/views/admin_mailer/_new_trending_statuses.text.erb
@@ -2,7 +2,7 @@
 
 <% @statuses.each do |status| %>
 - <%= ActivityPub::TagManager.instance.url_for(status) %>
-  <%= standard_locale_name(status.language) %> • <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %>
+  <%= standard_locale_name(status.language) %> · <%= raw t('admin.trends.tags.current_score', score: status.trend.score.round(2)) %>
 <% end %>
 
 <%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %>
diff --git a/app/views/admin_mailer/_new_trending_tags.text.erb b/app/views/admin_mailer/_new_trending_tags.text.erb
index 363df369d..d528ab8eb 100644
--- a/app/views/admin_mailer/_new_trending_tags.text.erb
+++ b/app/views/admin_mailer/_new_trending_tags.text.erb
@@ -2,7 +2,7 @@
 
 <% @tags.each do |tag| %>
 - #<%= tag.display_name %>
-  <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
+  <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> · <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
 <% end %>
 
 <% if @lowest_trending_tag %>
diff --git a/app/views/oauth/authorized_applications/index.html.haml b/app/views/oauth/authorized_applications/index.html.haml
index 55d8524db..689f05102 100644
--- a/app/views/oauth/authorized_applications/index.html.haml
+++ b/app/views/oauth/authorized_applications/index.html.haml
@@ -23,7 +23,7 @@
           - else
             = t('doorkeeper.authorized_applications.index.never_used')
 
-          •
+          ·
 
           = t('doorkeeper.authorized_applications.index.authorized_at', date: l(application.created_at.to_date))
 
diff --git a/lib/linter/haml_middle_dot.rb b/lib/linter/haml_middle_dot.rb
new file mode 100644
index 000000000..3b2771152
--- /dev/null
+++ b/lib/linter/haml_middle_dot.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module HamlLint
+  # Bans the usage of “•” (bullet) in HTML/HAML in favor of “·” (middle dot) in anything that will end up as a text node. (including string literals in Ruby code)
+  class Linter::MiddleDot < Linter
+    include LinterRegistry
+
+    # rubocop:disable Style/MiddleDot
+    BULLET = '•'
+    # rubocop:enable Style/MiddleDot
+    MIDDLE_DOT = '·'
+    MESSAGE = "Use '#{MIDDLE_DOT}' (middle dot) instead of '#{BULLET}' (bullet)".freeze
+
+    def visit_plain(node)
+      return unless node.text.include?(BULLET)
+
+      record_lint(node, MESSAGE)
+    end
+
+    def visit_script(node)
+      return unless node.script.include?(BULLET)
+
+      record_lint(node, MESSAGE)
+    end
+  end
+end
diff --git a/lib/linter/rubocop_middle_dot.rb b/lib/linter/rubocop_middle_dot.rb
new file mode 100644
index 000000000..3a1d97c0c
--- /dev/null
+++ b/lib/linter/rubocop_middle_dot.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module RuboCop
+  module Cop
+    module Style
+      # Bans the usage of “•” (bullet) in HTML/HAML in favor of “·” (middle dot) in string literals
+      class MiddleDot < Base
+        extend AutoCorrector
+        extend Util
+
+        # rubocop:disable Style/MiddleDot
+        BULLET = '•'
+        # rubocop:enable Style/MiddleDot
+        MIDDLE_DOT = '·'
+        MESSAGE = "Use '#{MIDDLE_DOT}' (middle dot) instead of '#{BULLET}' (bullet)".freeze
+
+        def on_str(node)
+          # Constants like __FILE__ are handled as strings,
+          # but don't respond to begin.
+          return unless node.loc.respond_to?(:begin) && node.loc.begin
+
+          return unless node.value.include?(BULLET)
+
+          add_offense(node, message: MESSAGE) do |corrector|
+            corrector.replace(node, node.source.gsub(BULLET, MIDDLE_DOT))
+          end
+        end
+      end
+    end
+  end
+end