# frozen_string_literal: true
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
module Helpers
# @since 0.1.0
# @api private
NEW_LINE_SEPARATOR = "\n"
# @since 0.1.0
# @api private
WILDCARD_EXT = ".*"
# @since 0.1.0
# @api private
JAVASCRIPT_EXT = ".js"
# @since 0.1.0
# @api private
STYLESHEET_EXT = ".css"
# @since 0.1.0
# @api private
JAVASCRIPT_MIME_TYPE = "text/javascript"
# @since 0.1.0
# @api private
STYLESHEET_MIME_TYPE = "text/css"
# @since 0.1.0
# @api private
FAVICON_MIME_TYPE = "image/x-icon"
# @since 0.1.0
# @api private
STYLESHEET_REL = "stylesheet"
# @since 0.1.0
# @api private
FAVICON_REL = "shortcut icon"
# @since 0.1.0
# @api private
DEFAULT_FAVICON = "favicon.ico"
# @since 0.3.0
# @api private
CROSSORIGIN_ANONYMOUS = "anonymous"
# @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)
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)
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' %>
#
# #
#
# @example Custom alt Attribute
#
# <%= image 'logo.png', alt: 'Application Logo' %>
#
# #
#
# @example Custom HTML Attributes
#
# <%= image 'logo.png', id: 'logo', class: 'image' %>
#
# #
#
# @example Absolute URL
#
# <%= image 'https://example-cdn.com/images/logo.png' %>
#
# #
#
# @example Fingerprint Mode
#
# <%= image 'logo.png' %>
#
# #
#
# @example CDN Mode
#
# <%= image 'logo.png' %>
#
# #
#
# @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
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
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, 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 Metrics/ModuleLength
end
end