discourse/lib/svg_sprite.rb
Kelv 013bf88019
DEV: convert icon name earlier for plugins in test env (#31196)
This makes the backtrace for a deprecated icon name error clearer in
terms of pointing out which plugin the icon is being registered from.
The error will only be raised in the test environment.

This PR also extracts out to a separate module the hashmaps & logic for
converting FA4 era icons to discourse FA6 compatible icons. This
isolates that logic for reuse in DiscoursePluginRegistry - otherwise
pulling in the whole SvgSprite resulted in errors.
2025-02-13 07:20:23 +08:00

534 lines
12 KiB
Ruby

# frozen_string_literal: true
require_relative "deprecated_icon_handler"
module SvgSprite
SVG_ICONS =
Set.new(
%w[
a
address-book
align-left
anchor
angle-down
angle-right
angle-up
angles-down
angles-left
angles-right
angles-up
arrow-down
arrow-left
arrow-right
arrow-rotate-left
arrow-rotate-right
arrow-up
arrows-rotate
asterisk
at
backward
backward-fast
backward-step
ban
bars
bars-staggered
bed
bell
bell-slash
bold
book
book-open-reader
bookmark
bookmark-delete
box-archive
briefcase
bullseye
calendar-days
caret-down
caret-left
caret-right
caret-up
certificate
chart-bar
chart-pie
check
chevron-down
chevron-left
chevron-right
chevron-up
circle
circle-check
circle-chevron-down
circle-exclamation
circle-half-stroke
circle-info
circle-minus
circle-plus
circle-question
circle-xmark
clock
clock-rotate-left
cloud-arrow-up
code
comment
compress
copy
crosshairs
cube
desktop
diagram-project
discourse-amazon
discourse-bell-exclamation
discourse-bell-one
discourse-bell-slash
discourse-bookmark-clock
discourse-chevron-collapse
discourse-chevron-expand
discourse-compress
discourse-dnd
discourse-emojis
discourse-expand
discourse-other-tab
discourse-sidebar
discourse-sparkles
discourse-threads
download
earth-americas
ellipsis
ellipsis-vertical
envelope
eye
fab-android
fab-apple
fab-chrome
fab-discord
fab-discourse
fab-facebook
fab-facebook-square
fab-github
fab-instagram
fab-linkedin-in
fab-linux
fab-markdown
fab-threads
fab-threads-square
fab-twitter
fab-twitter-square
fab-x-twitter
fab-wikipedia-w
fab-windows
far-bell
far-bell-slash
far-calendar-plus
far-chart-bar
far-circle
far-circle-dot
far-clipboard
far-clock
far-comment
far-comments
far-copyright
far-envelope
far-eye
far-eye-slash
far-face-frown
far-face-meh
far-face-smile
far-file-lines
far-heart
far-image
far-moon
far-pen-to-square
far-rectangle-list
far-square
far-square-check
far-star
far-sun
far-thumbs-down
far-thumbs-up
far-trash-can
file
file-lines
filter
flag
flask
folder
folder-open
forward
forward-fast
forward-step
gavel
gear
gift
globe
grip-lines
hand-point-right
handshake-angle
heart
hourglass-start
house
id-card
image
images
inbox
italic
key
keyboard
language
layer-group
left-right
link
link-slash
list
list-check
list-ol
list-ul
location-dot
lock
magnifying-glass
magnifying-glass-minus
magnifying-glass-plus
microphone-slash
minus
mobile-screen-button
moon
paintbrush
palette
paper-plane
pause
pencil
play
plug
plus
power-off
puzzle-piece
question
quote-left
quote-right
reply
right-from-bracket
right-left
right-to-bracket
robot
rocket
rotate
scroll
share
shield-halved
shuffle
signal
sliders
spinner
square-check
square-envelope
square-full
square-plus
star
sun
table
table-cells
table-columns
tag
tags
temperature-three-quarters
thumbs-down
thumbs-up
thumbtack
tippy-rounded-arrow
toggle-off
toggle-on
trash-can
triangle-exclamation
truck-medical
unlock
unlock-keyhole
up-down
up-right-from-square
upload
user
user-gear
user-group
user-pen
user-plus
user-secret
user-shield
user-xmark
users
wand-magic
wrench
xmark
],
)
CORE_SVG_SPRITES = Dir.glob("#{Rails.root}/vendor/assets/svg-icons/**/*.svg")
THEME_SPRITE_VAR_NAME = "icons-sprite"
MAX_THEME_SPRITE_SIZE = 1024.kilobytes
def self.preload
settings_icons
group_icons
badge_icons
end
def self.symbols_for(svg_filename, sprite, strict:)
if strict
Nokogiri.XML(sprite) { |config| config.options = Nokogiri::XML::ParseOptions::NOBLANKS }
else
Nokogiri.XML(sprite)
end.css("symbol")
.filter_map do |sym|
icon_id = prepare_symbol(sym, svg_filename)
if icon_id.present?
sym.attributes["id"].value = icon_id
sym.css("title").each(&:remove)
[icon_id, sym.to_xml]
end
end
.to_h
end
def self.core_svgs
@core_svgs ||=
CORE_SVG_SPRITES.reduce({}) do |symbols, path|
symbols.merge!(symbols_for(File.basename(path, ".svg"), File.read(path), strict: true))
end
end
# Just used in tests
def self.clear_plugin_svg_sprite_cache!
@plugin_svgs = nil
end
def self.plugin_svgs
@plugin_svgs ||=
begin
plugin_paths = []
Discourse
.plugins
.map { |plugin| File.dirname(plugin.path) }
.each { |path| plugin_paths << "#{path}/svg-icons/*.svg" }
custom_sprite_paths = Dir.glob(plugin_paths)
custom_sprite_paths.reduce({}) do |symbols, path|
symbols.merge!(symbols_for(File.basename(path, ".svg"), File.read(path), strict: true))
end
end
end
def self.theme_svgs(theme_id)
if theme_id.present?
cache
.defer_get_set_bulk(
Theme.transform_ids(theme_id),
lambda { |theme_id| "theme_svg_sprites_#{theme_id}" },
) do |theme_ids|
theme_field_uploads =
ThemeField.where(
type_id: ThemeField.types[:theme_upload_var],
name: THEME_SPRITE_VAR_NAME,
theme_id: theme_ids,
).pluck(:upload_id)
theme_sprites =
ThemeSvgSprite.where(theme_id: theme_ids).pluck(:theme_id, :upload_id, :sprite)
missing_sprites = (theme_field_uploads - theme_sprites.map(&:second))
if missing_sprites.present?
Rails.logger.warn(
"Missing ThemeSvgSprites for theme #{theme_id}, uploads #{missing_sprites.join(", ")}",
)
end
theme_sprites
.map do |(theme_id, upload_id, sprite)|
begin
[theme_id, symbols_for("theme_#{theme_id}_#{upload_id}.svg", sprite, strict: false)]
rescue => e
Rails.logger.warn(
"Bad XML in custom sprite in theme with ID=#{theme_id}. Error info: #{e.inspect}",
)
end
end
.compact
.to_h
.values_at(*theme_ids)
end
.values
.compact
.reduce({}) { |a, b| a.merge!(b) }
else
{}
end
end
def self.custom_svgs(theme_id)
plugin_svgs.merge(theme_svgs(theme_id))
end
def self.all_icons(theme_id = nil)
get_set_cache("icons_#{Theme.transform_ids(theme_id).join(",")}") do
Set
.new()
.merge(settings_icons)
.merge(plugin_icons)
.merge(badge_icons)
.merge(group_icons)
.merge(theme_icons(theme_id))
.merge(custom_icons(theme_id))
.delete_if { |i| i.blank? || i.include?("/") }
.map! { |i| process(i.dup) }
.merge(SVG_ICONS)
.sort
end
end
def self.version(theme_id = nil)
get_set_cache("version_#{Theme.transform_ids(theme_id).join(",")}") do
Digest::SHA1.hexdigest(bundle(theme_id))
end
end
def self.path(theme_id = nil)
"/svg-sprite/#{Discourse.current_hostname}/svg-#{theme_id}-#{version(theme_id)}.js"
end
def self.expire_cache
cache&.clear
end
def self.svgs_for(theme_id)
svgs = core_svgs
svgs = svgs.merge(custom_svgs(theme_id)) if theme_id.present?
svgs
end
def self.bundle(theme_id = nil)
icons = all_icons(theme_id)
svg_subset =
"" \
"<!--
Discourse SVG subset of Font Awesome Free by @fontawesome - https://fontawesome.com
License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
-->
<svg xmlns='http://www.w3.org/2000/svg' style='display: none;'>
" \
"".dup
svg_subset << core_svgs.slice(*icons).values.join
svg_subset << custom_svgs(theme_id).values.join
svg_subset << "</svg>"
end
def self.search(searched_icon)
searched_icon = process(searched_icon.dup)
svgs_for(SiteSetting.default_theme_id)[searched_icon] || false
end
def self.icon_picker_search(keyword, only_available = false)
symbols = svgs_for(SiteSetting.default_theme_id)
symbols.slice!(*all_icons(SiteSetting.default_theme_id)) if only_available
symbols.reject! { |icon_id, _sym| !icon_id.include?(keyword) } if keyword.present?
symbols.sort_by(&:first).map { |id, symbol| { id:, symbol: } }
end
# For use in no_ember .html.erb layouts
def self.raw_svg(name)
get_set_cache("raw_svg_#{name}") do
symbol = search(name)
break "" unless symbol
symbol = Nokogiri.XML(symbol).children.first
symbol.name = "svg"
<<~HTML
<svg class="fa d-icon svg-icon svg-node" aria-hidden="true">#{symbol}</svg>
HTML
end.html_safe
end
def self.theme_sprite_variable_name
THEME_SPRITE_VAR_NAME
end
def self.prepare_symbol(symbol, svg_filename = nil)
icon_id = symbol.attr("id")
case svg_filename
when "regular"
icon_id = icon_id.prepend("far-")
when "brands"
icon_id = icon_id.prepend("fab-")
end
icon_id
end
def self.settings_icons
get_set_cache("settings_icons") do
# includes svg_icon_subset and any settings containing _icon (incl. plugin settings)
site_setting_icons = []
SiteSetting.settings_hash.select do |key, value|
site_setting_icons |= value.split("|") if key.to_s.include?("_icon") && String === value
end
site_setting_icons
end
end
def self.plugin_icons
DiscoursePluginRegistry.svg_icons
end
def self.badge_icons
get_set_cache("badge_icons") { Badge.pluck(:icon).uniq }
end
def self.group_icons
get_set_cache("group_icons") { Group.pluck(:flair_icon).uniq }
end
def self.theme_icons(theme_id)
return [] if theme_id.blank?
theme_icon_settings = []
theme_ids = Theme.transform_ids(theme_id)
# Need to load full records for default values
Theme
.where(id: theme_ids)
.each do |theme|
_settings =
theme.cached_settings.each do |key, value|
if key.to_s.include?("_icon") && String === value
theme_icon_settings |= value.split("|")
end
end
end
theme_icon_settings |= ThemeModifierHelper.new(theme_ids: theme_ids).svg_icons
theme_icon_settings
end
def self.custom_icons(theme_id)
# Automatically register icons in sprites added via themes or plugins
custom_svgs(theme_id).keys
end
def self.process(icon_name)
DeprecatedIconHandler.convert_icon(icon_name.strip)
end
def self.get_set_cache(key, &block)
cache.defer_get_set(key, &block)
end
def self.cache
@cache ||= DistributedCache.new("svg_sprite")
end
end