require 'uri' require 'set' require 'thread' require 'hanami/helpers/html_helper' require 'hanami/utils/escape' module Hanami module Assets # HTML assets helpers # # Include this helper in a view # # @since 0.1.0 # # @see http://www.rubydoc.info/gems/hanami-helpers/Hanami/Helpers/HtmlHelper module Helpers # rubocop:disable Metrics/ModuleLength # @since 0.1.0 # @api private NEW_LINE_SEPARATOR = "\n".freeze # @since 0.1.0 # @api private WILDCARD_EXT = '.*'.freeze # @since 0.1.0 # @api private JAVASCRIPT_EXT = '.js'.freeze # @since 0.1.0 # @api private STYLESHEET_EXT = '.css'.freeze # @since 0.1.0 # @api private JAVASCRIPT_MIME_TYPE = 'text/javascript'.freeze # @since 0.1.0 # @api private STYLESHEET_MIME_TYPE = 'text/css'.freeze # @since 0.1.0 # @api private FAVICON_MIME_TYPE = 'image/x-icon'.freeze # @since 0.1.0 # @api private STYLESHEET_REL = 'stylesheet'.freeze # @since 0.1.0 # @api private FAVICON_REL = 'shortcut icon'.freeze # @since 0.1.0 # @api private DEFAULT_FAVICON = 'favicon.ico'.freeze # @since 0.3.0 # @api private CROSSORIGIN_ANONYMOUS = 'anonymous'.freeze # @since 0.3.0 # @api private ABSOLUTE_URL_MATCHER = URI::Parser.new.make_regexp include Hanami::Helpers::HtmlHelper # Inject helpers into the given class # # @since 0.1.0 # @api private def self.included(base) conf = ::Hanami::Assets::Configuration.for(base) base.class_eval do include Utils::ClassAttribute class_attribute :assets_configuration self.assets_configuration = conf end end # Generate script tag for given source(s) # # It accepts one or more strings representing the name of the asset, if it # comes from the application or third party gems. It also accepts strings # representing absolute URLs in case of public CDN (eg. jQuery CDN). # # If the "digest mode" is on, src is the digest version of the # relative URL. # # If the "CDN mode" is on, the src is an absolute URL of the # application CDN. # # @param sources [Array] one or more assets by name or absolute URL # # @return [Hanami::Utils::Escape::SafeString] the markup # # @raise [Hanami::Assets::MissingDigestAssetError] if digest mode is on and # at least one of the given sources is missing from the manifest # # @since 0.1.0 # # @see Hanami::Assets::Configuration#digest # @see Hanami::Assets::Configuration#cdn # @see Hanami::Assets::Helpers#asset_path # # @example Single Asset # # <%= javascript 'application' %> # # # # # @example Multiple Assets # # <%= javascript 'application', 'dashboard' %> # # # # # # # @example Asynchronous Execution # # <%= javascript 'application', async: true %> # # # # # @example Subresource Integrity # # <%= javascript 'application' %> # # # # # @example Subresource Integrity for 3rd Party Scripts # # <%= javascript 'https://example.com/assets/example.js', integrity: 'sha384-oqVu...Y8wC' %> # # # # # @example Deferred Execution # # <%= javascript 'application', defer: true %> # # # # # @example Absolute URL # # <%= javascript 'https://code.jquery.com/jquery-2.1.4.min.js' %> # # # # # @example Digest Mode # # <%= javascript 'application' %> # # # # # @example CDN Mode # # <%= javascript 'application' %> # # # def javascript(*sources, **options) _safe_tags(*sources) do |source| tag_options = options.dup tag_options[:src] ||= _typed_asset_path(source, JAVASCRIPT_EXT) tag_options[:type] ||= JAVASCRIPT_MIME_TYPE if _subresource_integrity? || tag_options.include?(:integrity) tag_options[:integrity] ||= _subresource_integrity_value(source, JAVASCRIPT_EXT) tag_options[:crossorigin] ||= CROSSORIGIN_ANONYMOUS end html.script(**tag_options).to_s end end # Generate link tag for given source(s) # # It accepts one or more strings representing the name of the asset, if it # comes from the application or third party gems. It also accepts strings # representing absolute URLs in case of public CDN (eg. Bootstrap CDN). # # If the "digest mode" is on, href is the digest version of the # relative URL. # # If the "CDN mode" is on, the href is an absolute URL of the # application CDN. # # @param sources [Array] one or more assets by name or absolute URL # # @return [Hanami::Utils::Escape::SafeString] the markup # # @raise [Hanami::Assets::MissingDigestAssetError] if digest mode is on and # at least one of the given sources is missing from the manifest # # @since 0.1.0 # # @see Hanami::Assets::Configuration#digest # @see Hanami::Assets::Configuration#cdn # @see Hanami::Assets::Helpers#asset_path # # @example Single Asset # # <%= stylesheet 'application' %> # # # # # @example Multiple Assets # # <%= stylesheet 'application', 'dashboard' %> # # # # # # # @example Subresource Integrity # # <%= stylesheet 'application' %> # # # # # @example Subresource Integrity for 3rd Party Assets # # <%= stylesheet 'https://example.com/assets/example.css', integrity: 'sha384-oqVu...Y8wC' %> # # # # # @example Absolute URL # # <%= stylesheet 'https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css' %> # # # # # @example Digest Mode # # <%= stylesheet 'application' %> # # # # # @example CDN Mode # # <%= stylesheet 'application' %> # # # def stylesheet(*sources, **options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength _safe_tags(*sources) do |source| tag_options = options.dup tag_options[:href] ||= _typed_asset_path(source, STYLESHEET_EXT) tag_options[:type] ||= STYLESHEET_MIME_TYPE tag_options[:rel] ||= STYLESHEET_REL if _subresource_integrity? || tag_options.include?(:integrity) tag_options[:integrity] ||= _subresource_integrity_value(source, STYLESHEET_EXT) tag_options[:crossorigin] ||= CROSSORIGIN_ANONYMOUS end html.link(**tag_options).to_s end end # Generate img tag for given source # # It accepts one string representing the name of the asset, if it comes # from the application or third party gems. It also accepts string # representing absolute URLs in case of public CDN (eg. Bootstrap CDN). # # alt Attribute is auto generated from src. # You can specify a different value, by passing the :src option. # # If the "digest mode" is on, src is the digest version of the # relative URL. # # If the "CDN mode" is on, the src is an absolute URL of the # application CDN. # # @param source [String] asset name or absolute URL # # @return [Hanami::Utils::Helpers::HtmlBuilder] the builder # # @raise [Hanami::Assets::MissingDigestAssetError] if digest mode is on and # the image is missing from the manifest # # @since 0.1.0 # # @see Hanami::Assets::Configuration#digest # @see Hanami::Assets::Configuration#cdn # @see Hanami::Assets::Helpers#asset_path # # @example Basic Usage # # <%= image 'logo.png' %> # # # Logo # # @example Custom alt Attribute # # <%= image 'logo.png', alt: 'Application Logo' %> # # # Application Logo # # @example Custom HTML Attributes # # <%= image 'logo.png', id: 'logo', class: 'image' %> # # # # # @example Absolute URL # # <%= image 'https://example-cdn.com/images/logo.png' %> # # # Logo # # @example Digest Mode # # <%= image 'logo.png' %> # # # Logo # # @example CDN Mode # # <%= image 'logo.png' %> # # # Logo def image(source, options = {}) options[:src] = asset_path(source) options[:alt] ||= Utils::String.new(::File.basename(source, WILDCARD_EXT)).titleize html.img(options) end # Generate link tag application favicon. # # If no argument is given, it assumes favico.ico from the application. # # It accepts one string representing the name of the asset. # # If the "digest mode" is on, href is the digest version of the # relative URL. # # If the "CDN mode" is on, the href is an absolute URL of the # application CDN. # # @param source [String] asset name # # @return [Hanami::Utils::Helpers::HtmlBuilder] the builder # # @raise [Hanami::Assets::MissingDigestAssetError] if digest mode is on and # the favicon is missing from the manifest # # @since 0.1.0 # # @see Hanami::Assets::Configuration#digest # @see Hanami::Assets::Configuration#cdn # @see Hanami::Assets::Helpers#asset_path # # @example Basic Usage # # <%= favicon %> # # # # # @example Custom Path # # <%= favicon 'fav.ico' %> # # # # # @example Custom HTML Attributes # # <%= favicon id: 'fav' %> # # # # # @example Digest Mode # # <%= favicon %> # # # # # @example CDN Mode # # <%= favicon %> # # # def favicon(source = DEFAULT_FAVICON, options = {}) options[:href] = asset_path(source) options[:rel] ||= FAVICON_REL options[:type] ||= FAVICON_MIME_TYPE html.link(options) end # Generate video tag for given source # # It accepts one string representing the name of the asset, if it comes # from the application or third party gems. It also accepts string # representing absolute URLs in case of public CDN (eg. Bootstrap CDN). # # Alternatively, it accepts a block that allows to specify one or more # sources via the source tag. # # If the "digest mode" is on, src is the digest version of the # relative URL. # # If the "CDN mode" is on, the src is an absolute URL of the # application CDN. # # @param source [String] asset name or absolute URL # # @return [Hanami::Utils::Helpers::HtmlBuilder] the builder # # @raise [Hanami::Assets::MissingDigestAssetError] if digest mode is on and # the image is missing from the manifest # # @raise [ArgumentError] if source isn't specified both as argument or # tag inside the given block # # @since 0.1.0 # # @see Hanami::Assets::Configuration#digest # @see Hanami::Assets::Configuration#cdn # @see Hanami::Assets::Helpers#asset_path # # @example Basic Usage # # <%= video 'movie.mp4' %> # # # # # @example Absolute URL # # <%= video 'https://example-cdn.com/assets/movie.mp4' %> # # # # # @example Custom HTML Attributes # # <%= video('movie.mp4', autoplay: true, controls: true) %> # # # # # @example Fallback Content # # <%= # video('movie.mp4') do # "Your browser does not support the video tag" # end # %> # # # # # @example Tracks # # <%= # video('movie.mp4') do # track(kind: 'captions', src: asset_path('movie.en.vtt'), # srclang: 'en', label: 'English') # end # %> # # # # # @example Sources # # <%= # video do # text "Your browser does not support the video tag" # source(src: asset_path('movie.mp4'), type: 'video/mp4') # source(src: asset_path('movie.ogg'), type: 'video/ogg') # end # %> # # # # # @example Without Any Argument # # <%= video %> # # # ArgumentError # # @example Without src And Without Block # # <%= video(content: true) %> # # # ArgumentError # # @example Digest Mode # # <%= video 'movie.mp4' %> # # # # # @example CDN Mode # # <%= video 'movie.mp4' %> # # # def video(source = nil, options = {}, &blk) options = _source_options(source, options, &blk) html.video(blk, options) end # Generate audio tag for given source # # It accepts one string representing the name of the asset, if it comes # from the application or third party gems. It also accepts string # representing absolute URLs in case of public CDN (eg. Bootstrap CDN). # # Alternatively, it accepts a block that allows to specify one or more # sources via the source tag. # # If the "digest mode" is on, src is the digest version of the # relative URL. # # If the "CDN mode" is on, the src is an absolute URL of the # application CDN. # # @param source [String] asset name or absolute URL # # @return [Hanami::Utils::Helpers::HtmlBuilder] the builder # # @raise [Hanami::Assets::MissingDigestAssetError] if digest mode is on and # the image is missing from the manifest # # @raise [ArgumentError] if source isn't specified both as argument or # tag inside the given block # # @since 0.1.0 # # @see Hanami::Assets::Configuration#digest # @see Hanami::Assets::Configuration#cdn # @see Hanami::Assets::Helpers#asset_path # # @example Basic Usage # # <%= audio 'song.ogg' %> # # # # # @example Absolute URL # # <%= audio 'https://example-cdn.com/assets/song.ogg' %> # # # # # @example Custom HTML Attributes # # <%= audio('song.ogg', autoplay: true, controls: true) %> # # # # # @example Fallback Content # # <%= # audio('song.ogg') do # "Your browser does not support the audio tag" # end # %> # # # # # @example Tracks # # <%= # audio('song.ogg') do # track(kind: 'captions', src: asset_path('song.pt-BR.vtt'), # srclang: 'pt-BR', label: 'Portuguese') # end # %> # # # # # @example Sources # # <%= # audio do # text "Your browser does not support the audio tag" # source(src: asset_path('song.ogg'), type: 'audio/ogg') # source(src: asset_path('song.wav'), type: 'auido/wav') # end # %> # # # # # @example Without Any Argument # # <%= audio %> # # # ArgumentError # # @example Without src And Without Block # # <%= audio(controls: true) %> # # # ArgumentError # # @example Digest Mode # # <%= audio 'song.ogg' %> # # # # # @example CDN Mode # # <%= audio 'song.ogg' %> # # # def audio(source = nil, options = {}, &blk) options = _source_options(source, options, &blk) html.audio(blk, options) end # It generates the relative URL for the given source. # # It can be the name of the asset, coming from the sources or third party # gems. # # Absolute URLs are returned as they are. # # If Digest mode is on, it returns the digest path of the source # # If CDN mode is on, it returns the absolute URL of the asset. # # @param source [String] the asset name # # @return [String] the asset path # # @raise [Hanami::Assets::MissingDigestAssetError] if digest mode is on and # the asset is missing from the manifest # # @since 0.1.0 # # @example Basic Usage # # <%= asset_path 'application.js' %> # # # "/assets/application.js" # # @example Absolute URL # # <%= asset_path 'https://code.jquery.com/jquery-2.1.4.min.js' %> # # # "https://code.jquery.com/jquery-2.1.4.min.js" # # @example Digest Mode # # <%= asset_path 'application.js' %> # # # "/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.js" # # @example CDN Mode # # <%= asset_path 'application.js' %> # # # "https://assets.bookshelf.org/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.js" def asset_path(source) _asset_url(source) { _relative_url(source) } end # It generates the absolute URL for the given source. # # It can be the name of the asset, coming from the sources or third party # gems. # # Absolute URLs are returned as they are. # # If Digest mode is on, it returns the digest URL of the source # # If CDN mode is on, it returns the absolute URL of the asset. # # @param source [String] the asset name # # @return [String] the asset URL # # @raise [Hanami::Assets::MissingDigestAssetError] if digest mode is on and # the asset is missing from the manifest # # @since 0.1.0 # # @example Basic Usage # # <%= asset_url 'application.js' %> # # # "https://bookshelf.org/assets/application.js" # # @example Absolute URL # # <%= asset_url 'https://code.jquery.com/jquery-2.1.4.min.js' %> # # # "https://code.jquery.com/jquery-2.1.4.min.js" # # @example Digest Mode # # <%= asset_url 'application.js' %> # # # "https://bookshelf.org/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.js" # # @example CDN Mode # # <%= asset_url 'application.js' %> # # # "https://assets.bookshelf.org/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.js" def asset_url(source) _asset_url(source) { _absolute_url(source) } end private # @since 0.1.0 # @api private def _safe_tags(*sources) ::Hanami::Utils::Escape::SafeString.new( sources.map do |source| yield source end.join(NEW_LINE_SEPARATOR) ) end # @since 0.1.0 # @api private def _asset_url(source) _push_promise( _absolute_url?(source) ? # rubocop:disable Style/MultilineTernaryOperator source : yield ) end # @since 0.1.0 # @api private def _typed_asset_path(source, ext) source = "#{source}#{ext}" unless source =~ /#{Regexp.escape(ext)}\z/ asset_path(source) end def _subresource_integrity? !!self.class.assets_configuration.subresource_integrity # rubocop:disable Style/DoubleNegation end def _subresource_integrity_value(source, ext) source = "#{source}#{ext}" unless source =~ /#{Regexp.escape(ext)}\z/ self.class.assets_configuration.subresource_integrity_value(source) unless _absolute_url?(source) end # @since 0.1.0 # @api private def _absolute_url?(source) ABSOLUTE_URL_MATCHER.match(source) end # @since 0.1.0 # @api private def _relative_url(source) self.class.assets_configuration.asset_path(source) end # @since 0.1.0 # @api private def _absolute_url(source) self.class.assets_configuration.asset_url(source) end # @since 0.1.0 # @api private def _source_options(src, options, &_blk) options ||= {} if src.respond_to?(:to_hash) options = src.to_hash elsif src options[:src] = asset_path(src) end if !options[:src] && !block_given? raise ArgumentError.new('You should provide a source via `src` option or with a `source` HTML tag') end options end # @since 0.1.0 # @api private def _push_promise(url) Mutex.new.synchronize do Thread.current[:__hanami_assets] ||= Set.new Thread.current[:__hanami_assets].add(url.to_s) end url end end end end