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' %>
#
# #
#
# @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 Digest Mode
#
# <%= image 'logo.png' %>
#
# #
#
# @example CDN Mode
#
# <%= image 'logo.png' %>
#
# #
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