discourse/lib/svg_sprite.rb
Osama Sayegh f87e5aab0b UX: Tweaks to the theme/component pages when using admin sidebar (#30953)
There are a number of minor changes in this commit :

1. Combine the "Themes" and "Components" links in the admin sidebar into
a single tab labelled "Themes and components"
2. The combined tab links to the `/admin/config/customize/themes` page
(titled as "Themes and components")
3. Add a new "Components" tab to the "Themes and components" page.
There's already an existing "Themes" tab
4. Add a "back to" link at the top of individual theme/component page to
navigate back to the respective tab in the "Themes and components" page
5. Remove the themes/components list/sidebar that currently serves for
navigating between themes/components
6. Remove the header in the theme/component page

Changes 4–6 apply only if the admin sidebar is enabled; they have no
effect otherwise.

Internal topic: t/146006.
2025-03-13 15:34:17 +03:00

536 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-left
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
hashtag
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