# frozen_string_literal: true module SvgSprite SVG_ICONS ||= Set.new( %w[ adjust address-book align-left ambulance anchor angle-double-down angle-double-up angle-double-right angle-double-left angle-down angle-right angle-up archive arrow-down arrow-left arrow-up arrows-alt-h arrows-alt-v at asterisk backward ban bars bed bell bell-slash bold book book-reader bookmark briefcase bullseye calendar-alt caret-down caret-left caret-right caret-up certificate chart-bar chart-pie check check-circle check-square chevron-circle-down chevron-down chevron-left chevron-right chevron-up circle cloud-upload-alt code cog columns comment compress copy crosshairs cube desktop discourse-amazon discourse-bell-exclamation discourse-bell-one discourse-bell-slash discourse-bookmark-clock discourse-compress discourse-emojis discourse-expand discourse-other-tab download ellipsis-h ellipsis-v envelope envelope-square exchange-alt exclamation-circle exclamation-triangle external-link-alt fab-android fab-apple fab-chrome fab-discord fab-discourse fab-facebook-square fab-facebook fab-github fab-instagram fab-linux fab-twitter fab-twitter-square fab-wikipedia-w fab-windows far-bell far-bell-slash far-calendar-plus far-chart-bar far-check-square far-circle far-clipboard far-clock far-comment far-comments far-copyright far-dot-circle far-edit far-envelope far-eye far-eye-slash far-file-alt far-frown far-heart far-image far-list-alt far-meh far-moon far-smile far-square far-star far-sun far-thumbs-down far-thumbs-up far-trash-alt fast-backward fast-forward file file-alt filter flag folder folder-open forward gavel gift globe globe-americas grip-lines hand-point-right hands-helping heart history home hourglass-start id-card image inbox info-circle italic key keyboard layer-group link list list-ol list-ul lock magic map-marker-alt microphone-slash minus minus-circle mobile-alt moon paint-brush paper-plane pause pencil-alt play plug plus plus-circle plus-square power-off puzzle-piece question question-circle quote-left quote-right random redo reply rocket search share shield-alt sign-in-alt sign-out-alt signal sliders-h square-full star step-backward step-forward stream sync-alt sync table tag tags tasks thermometer-three-quarters thumbs-down thumbs-up thumbtack times times-circle toggle-off toggle-on trash-alt undo unlink unlock unlock-alt upload user user-cog user-edit user-friends user-plus user-secret user-shield user-times users wrench spinner tippy-rounded-arrow ], ) FA_ICON_MAP = { "far fa-" => "far-", "fab fa-" => "fab-", "fas fa-" => "", "fa-" => "" } CORE_SVG_SPRITES = Dir.glob("#{Rails.root}/vendor/assets/svg-icons/**/*.svg") THEME_SPRITE_VAR_NAME = "icons-sprite" def self.preload settings_icons group_icons badge_icons end def self.custom_svg_sprites(theme_id) get_set_cache("custom_svg_sprites_#{Theme.transform_ids(theme_id).join(",")}") do 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_sprites = custom_sprite_paths.map do |path| if File.exist?(path) { filename: "#{File.basename(path, ".svg")}", sprite: File.read(path) } end end if theme_id.present? ThemeField .where( type_id: ThemeField.types[:theme_upload_var], name: THEME_SPRITE_VAR_NAME, theme_id: Theme.transform_ids(theme_id), ) .pluck(:upload_id, :theme_id) .each do |upload_id, child_theme_id| begin upload = Upload.find(upload_id) custom_sprites << { filename: "theme_#{theme_id}_#{upload_id}.svg", sprite: upload.content, } rescue => e name = begin Theme.find(child_theme_id).name rescue StandardError nil end Discourse.warn_exception(e, message: "#{name} theme contains a corrupt svg upload") end end end custom_sprites end 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.sprite_sources(theme_id) sprites = [] CORE_SVG_SPRITES.each do |path| if File.exist?(path) sprites << { filename: "#{File.basename(path, ".svg")}", sprite: File.read(path) } end end sprites = sprites + custom_svg_sprites(theme_id) if theme_id.present? sprites end def self.core_svgs @core_svgs ||= begin symbols = {} CORE_SVG_SPRITES.each do |filename| svg_filename = "#{File.basename(filename, ".svg")}" Nokogiri .XML(File.open(filename)) do |config| config.options = Nokogiri::XML::ParseOptions::NOBLANKS end .css("symbol") .each do |sym| icon_id = prepare_symbol(sym, svg_filename) sym.attributes["id"].value = icon_id symbols[icon_id] = sym.to_xml end end symbols end end def self.bundle(theme_id = nil) icons = all_icons(theme_id) svg_subset = "" \ " " \ "".dup core_svgs.each { |icon_id, sym| svg_subset << sym if icons.include?(icon_id) } custom_svg_sprites(theme_id).each do |item| begin svg_file = Nokogiri.XML(item[:sprite]) do |config| config.options = Nokogiri::XML::ParseOptions::NOBLANKS end rescue => e Rails.logger.warn( "Bad XML in custom sprite in theme with ID=#{theme_id}. Error info: #{e.inspect}", ) end next if !svg_file svg_file .css("symbol") .each do |sym| icon_id = prepare_symbol(sym, item[:filename]) if icons.include? icon_id sym.attributes["id"].value = icon_id sym.css("title").each(&:remove) svg_subset << sym.to_xml end end end svg_subset << "" end def self.search(searched_icon) searched_icon = process(searched_icon.dup) sprite_sources(SiteSetting.default_theme_id).each do |item| svg_file = Nokogiri.XML(item[:sprite]) svg_file .css("symbol") .each do |sym| icon_id = prepare_symbol(sym, item[:filename]) if searched_icon == icon_id sym.attributes["id"].value = icon_id sym.css("title").each(&:remove) return sym.to_xml end end end false end def self.icon_picker_search(keyword, only_available = false) icons = all_icons(SiteSetting.default_theme_id) if only_available results = Set.new sprite_sources(SiteSetting.default_theme_id).each do |item| svg_file = Nokogiri.XML(item[:sprite]) svg_file .css("symbol") .each do |sym| icon_id = prepare_symbol(sym, item[:filename]) next if only_available && !icons.include?(icon_id) if keyword.empty? || icon_id.include?(keyword) sym.attributes["id"].value = icon_id sym.css("title").each(&:remove) results.add(id: icon_id, symbol: sym.to_xml) end end end results.sort_by { |icon| icon[:id] } 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 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 icons = [] custom_svg_sprites(theme_id).each do |item| svg_file = Nokogiri.XML(item[:sprite]) svg_file .css("symbol") .each { |sym| icons << sym.attributes["id"].value if sym.attributes["id"].present? } end icons end def self.process(icon_name) icon_name = icon_name.strip FA_ICON_MAP.each { |k, v| icon_name = icon_name.sub(k, v) } icon_name 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