require 'uri' 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 # # rubocop:disable Metrics/ModuleLength # rubocop:disable Naming/UncommunicativeMethodParamName module Helpers # @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::DEFAULT_PARSER.make_regexp # @since 1.1.0 # @api private QUERY_STRING_MATCHER = /\?/.freeze 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 "fingerprint mode" is on, src is the fingerprinted # version of the relative URL. # # If the "CDN mode" is on, the src is an absolute URL of the # application CDN. # # If the "subresource integrity mode" is on, integriy is the # name of the algorithm, then a hyphen, then the hash value of the file. # If more than one algorithm is used, they'll be separated by a space. # # It makes the script(s) eligible for HTTP/2 Push Promise/Early Hints. # You can opt-out with inline option: `push: false`. # # @param sources [Array] one or more assets by name or absolute URL # @param push [TrueClass, FalseClass] HTTP/2 Push Promise/Early Hints flag # # @return [Hanami::Utils::Escape::SafeString] the markup # # @raise [Hanami::Assets::MissingManifestAssetError] if `fingerprint` or # `subresource_integrity` modes are on and the javascript file is missing # from the manifest # # @since 0.1.0 # # @see Hanami::Assets::Configuration#fingerprint # @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 Fingerprint Mode # # <%= javascript 'application' %> # # # # # @example CDN Mode # # <%= javascript 'application' %> # # # # # @example Disable Push Promise/Early Hints # # <%= javascript 'application', push: false %> # <%= javascript 'http://cdn.example.test/jquery.js', 'dashboard', push: false %> def javascript(*sources, push: true, **options) # rubocop:disable Metrics/MethodLength options = options.reject { |k, _| k.to_sym == :src } _safe_tags(*sources) do |source| attributes = { src: _typed_asset_path(source, JAVASCRIPT_EXT, push: push, as: :script), type: JAVASCRIPT_MIME_TYPE } attributes.merge!(options) if _subresource_integrity? || attributes.include?(:integrity) attributes[:integrity] ||= _subresource_integrity_value(source, JAVASCRIPT_EXT) attributes[:crossorigin] ||= CROSSORIGIN_ANONYMOUS end html.script(**attributes).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 "fingerprint mode" is on, href is the fingerprinted # version of the relative URL. # # If the "CDN mode" is on, the href is an absolute URL of the # application CDN. # # If the "subresource integrity mode" is on, integriy is the # name of the algorithm, then a hyphen, then the hashed value of the file. # If more than one algorithm is used, they'll be separated by a space. # # It makes the script(s) eligible for HTTP/2 Push Promise/Early Hints. # You can opt-out with inline option: `push: false`. # # @param sources [Array] one or more assets by name or absolute URL # @param push [TrueClass, FalseClass] HTTP/2 Push Promise/Early Hints flag # # @return [Hanami::Utils::Escape::SafeString] the markup # # @raise [Hanami::Assets::MissingManifestAssetError] if `fingerprint` or # `subresource_integrity` modes are on and the stylesheet file is missing # from the manifest # # @since 0.1.0 # # @see Hanami::Assets::Configuration#fingerprint # @see Hanami::Assets::Configuration#cdn # @see Hanami::Assets::Configuration#subresource_integrity # @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 Fingerprint Mode # # <%= stylesheet 'application' %> # # # # # @example CDN Mode # # <%= stylesheet 'application' %> # # # # # @example Disable Push Promise/Early Hints # # <%= stylesheet 'application', push: false %> # <%= stylesheet 'http://cdn.example.test/bootstrap.css', 'dashboard', push: false %> def stylesheet(*sources, push: true, **options) # rubocop:disable Metrics/MethodLength options = options.reject { |k, _| k.to_sym == :href } _safe_tags(*sources) do |source| attributes = { href: _typed_asset_path(source, STYLESHEET_EXT, push: push, as: :style), type: STYLESHEET_MIME_TYPE, rel: STYLESHEET_REL } attributes.merge!(options) if _subresource_integrity? || attributes.include?(:integrity) attributes[:integrity] ||= _subresource_integrity_value(source, STYLESHEET_EXT) attributes[:crossorigin] ||= CROSSORIGIN_ANONYMOUS end html.link(**attributes).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 "fingerprint mode" is on, src is the fingerprinted # 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 # @param options [Hash] HTML 5 attributes # @option options [TrueClass, FalseClass] :push HTTP/2 Push Promise/Early Hints flag # # @return [Hanami::Utils::Helpers::HtmlBuilder] the builder # # @raise [Hanami::Assets::MissingManifestAssetError] if `fingerprint` or # `subresource_integrity` modes are on and the image file is missing # from the manifest # # @since 0.1.0 # # @see Hanami::Assets::Configuration#fingerprint # @see Hanami::Assets::Configuration#cdn # @see Hanami::Assets::Configuration#subresource_integrity # @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 Fingerprint Mode # # <%= image 'logo.png' %> # # # Logo # # @example CDN Mode # # <%= image 'logo.png' %> # # # Logo # # @example Enable Push Promise/Early Hints # # <%= image 'logo.png', push: true %> def image(source, options = {}) options = options.reject { |k, _| k.to_sym == :src } attributes = { src: asset_path(source, push: options.delete(:push) || false, as: :image), alt: Utils::String.titleize(::File.basename(source, WILDCARD_EXT)) } attributes.merge!(options) html.img(attributes) 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 "fingerprint mode" is on, href is the fingerprinted 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 # @param options [Hash] HTML 5 attributes # @option options [TrueClass, FalseClass] :push HTTP/2 Push Promise/Early Hints flag # # @return [Hanami::Utils::Helpers::HtmlBuilder] the builder # # @raise [Hanami::Assets::MissingManifestAssetError] if `fingerprint` or # `subresource_integrity` modes are on and the favicon is file missing # from the manifest # # @since 0.1.0 # # @see Hanami::Assets::Configuration#fingerprint # @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 "favicon.ico", id: "fav" %> # # # # # @example Fingerprint Mode # # <%= favicon %> # # # # # @example CDN Mode # # <%= favicon %> # # # # # @example Enable Push Promise/Early Hints # # <%= favicon 'favicon.ico', push: true %> def favicon(source = DEFAULT_FAVICON, options = {}) options = options.reject { |k, _| k.to_sym == :href } attributes = { href: asset_path(source, push: options.delete(:push) || false, as: :image), rel: FAVICON_REL, type: FAVICON_MIME_TYPE } attributes.merge!(options) html.link(attributes) 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 "fingerprint mode" is on, src is the fingerprinted # 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 # @param options [Hash] HTML 5 attributes # @option options [TrueClass, FalseClass] :push HTTP/2 Push Promise/Early Hints flag # # @return [Hanami::Utils::Helpers::HtmlBuilder] the builder # # @raise [Hanami::Assets::MissingManifestAssetError] if `fingerprint` or # `subresource_integrity` modes are on and the video file 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#fingerprint # @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 Fingerprint Mode # # <%= video 'movie.mp4' %> # # # # # @example CDN Mode # # <%= video 'movie.mp4' %> # # # # # @example Enable Push Promise/Early Hints # # <%= video 'movie.mp4', push: true %> # # <%= # video do # text "Your browser does not support the video tag" # source src: asset_path("movie.mp4", push: :video), type: "video/mp4" # source src: asset_path("movie.ogg"), type: "video/ogg" # end # %> def video(source = nil, options = {}, &blk) options = _source_options(source, options, as: :video, &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 "fingerprint mode" is on, src is the fingerprinted # 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 # @param options [Hash] HTML 5 attributes # @option options [TrueClass, FalseClass] :push HTTP/2 Push Promise/Early Hints flag # # @return [Hanami::Utils::Helpers::HtmlBuilder] the builder # # @raise [Hanami::Assets::MissingManifestAssetError] if `fingerprint` or # `subresource_integrity` modes are on and the audio file 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#fingerprint # @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 Fingerprint Mode # # <%= audio 'song.ogg' %> # # # # # @example CDN Mode # # <%= audio 'song.ogg' %> # # # # # @example Enable Push Promise/Early Hints # # <%= audio 'movie.mp4', push: true %> # # <%= # audio do # text "Your browser does not support the audio tag" # source src: asset_path("song.ogg", push: :audio), type: "audio/ogg" # source src: asset_path("song.wav"), type: "audio/wav" # end # %> def audio(source = nil, options = {}, &blk) options = _source_options(source, options, as: :audio, &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 Fingerprint mode is on, it returns the fingerprinted path of the source # # If CDN mode is on, it returns the absolute URL of the asset. # # @param source [String] the asset name # @param push [TrueClass, FalseClass, Symbol] HTTP/2 Push Promise/Early Hints flag, or type # @param as [Symbol] HTTP/2 Push Promise / Early Hints flag type # # @return [String] the asset path # # @raise [Hanami::Assets::MissingManifestAssetError] if `fingerprint` or # `subresource_integrity` modes are 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 Fingerprint Mode # # <%= asset_path 'application.js' %> # # # "/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.js" # # @example CDN Mode # # <%= asset_path 'application.js' %> # # # "https://assets.bookshelf.org/assets/application-28a6b886de2372ee3922fcaf3f78f2d8.js" # # @example Enable Push Promise/Early Hints # # <%= asset_path "application.js", push: :script %> def asset_path(source, push: false, as: nil) _asset_url(source, push: push, as: as) { _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 Fingerprint mode is on, it returns the fingerprint URL of the source # # If CDN mode is on, it returns the absolute URL of the asset. # # @param source [String] the asset name # @param push [TrueClass, FalseClass, Symbol] HTTP/2 Push Promise/Early Hints flag, or type # @param as [Symbol] HTTP/2 Push Promise / Early Hints flag type # # @return [String] the asset URL # # @raise [Hanami::Assets::MissingManifestAssetError] if `fingerprint` or # `subresource_integrity` modes are 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 Fingerprint 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" # # @example Enable Push Promise/Early Hints # # <%= asset_url "application.js", push: :script %> def asset_url(source, push: false, as: nil) _asset_url(source, push: push, as: as) { _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:, as:) url = _absolute_url?(source) ? source : yield case push when Symbol _push_promise(url, as: push) when TrueClass _push_promise(url, as: as) end url end # @since 0.1.0 # @api private def _typed_asset_path(source, ext, push: false, as: nil) source = "#{source}#{ext}" if _append_extension?(source, ext) asset_path(source, push: push, as: as) end # @api private def _subresource_integrity? !!self.class.assets_configuration.subresource_integrity # rubocop:disable Style/DoubleNegation end # @api private 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 1.2.0 # @api private def _crossorigin?(source) return false unless _absolute_url?(source) self.class.assets_configuration.crossorigin?(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, as:, &_blk) options ||= {} if src.respond_to?(:to_hash) options = src.to_hash elsif src options[:src] = asset_path(src, push: options.delete(:push) || false, as: as) end raise ArgumentError.new('You should provide a source via `src` option or with a `source` HTML tag') if !options[:src] && !block_given? options end # @since 0.1.0 # @api private def _push_promise(url, as: nil) Thread.current[:__hanami_assets] ||= {} Thread.current[:__hanami_assets][url.to_s] = { as: as, crossorigin: _crossorigin?(url) } url end # @since 1.1.0 # @api private def _append_extension?(source, ext) source !~ QUERY_STRING_MATCHER && source !~ /#{Regexp.escape(ext)}\z/ end end # rubocop:enable Naming/UncommunicativeMethodParamName # rubocop:enable Metrics/ModuleLength end end