# frozen_string_literal: true
require "active_support/html_safe_translation"
module Bridgetown
class RubyTemplateView
class Helpers
include Bridgetown::Filters
include Bridgetown::Filters::FromLiquid
# @return [Bridgetown::RubyTemplateView]
attr_reader :view
# @return [Bridgetown::Site]
attr_reader :site
Context = Struct.new(:registers)
# @param view [Bridgetown::RubyTemplateView]
# @param site [Bridgetown::Site]
def initialize(view, site)
@view = view
@site = site
# duck typing for Liquid context
@context = Context.new({ site: site })
end
def asset_path(asset_type)
Bridgetown::Utils.parse_frontend_manifest_file(site, asset_type.to_s)
end
alias_method :webpack_path, :asset_path
def live_reload_dev_js
Bridgetown::Utils.live_reload_js(site)
end
# @param pairs [Hash] A hash of key/value pairs.
#
# @return [String] Space-separated keys where the values are truthy.
def class_map(pairs = {})
pairs.select { |_key, truthy| truthy }.keys.join(" ")
end
# Convert a Markdown string into HTML output.
#
# @param input [String] the Markdown to convert, if no block is passed
# @return [String]
def markdownify(input = nil, &block)
content = Bridgetown::Utils.reindent_for_markdown(
block.nil? ? input.to_s : view.capture(&block)
)
converter = site.find_converter_instance(Bridgetown::Converters::Markdown)
safe(converter.convert(content).strip)
end
# This helper will generate the correct permalink URL for the file path.
#
# @param relative_path [String, Object] source file path, e.g.
# "_posts/2020-10-20-my-post.md", or object that responds to either
# `url` or `relative_url`
# @return [String] the permalink URL for the file
def url_for(relative_path)
if relative_path.respond_to?(:relative_url)
return safe(relative_path.relative_url) # new resource engine
elsif relative_path.respond_to?(:url)
return safe(relative_url(relative_path.url)) # old legacy engine
elsif relative_path.to_s.start_with?("/", "http", "#", "mailto:", "tel:")
return safe(relative_path)
end
find_relative_url_for_path(relative_path)
end
alias_method :link, :url_for
# @param relative_path [String] source file path, e.g.
# "_posts/2020-10-20-my-post.md"
# @raise [ArgumentError] if the file cannot be found
def find_relative_url_for_path(relative_path)
site.each_site_file do |item|
if item.relative_path.to_s == relative_path ||
item.relative_path.to_s == "/#{relative_path}"
return safe(item.respond_to?(:relative_url) ? item.relative_url : relative_url(item))
end
end
raise ArgumentError, <<~MSG
Could not find document '#{relative_path}' in 'url_for' helper.
Make sure the document exists and the path is correct.
MSG
end
# This helper will generate the correct permalink URL for the file path.
#
# @param text [String] the content inside the anchor tag
# @param relative_path [String, Object] source file path, e.g.
# "_posts/2020-10-20-my-post.md", or object that responds to `url`
# @param options [Hash] key-value pairs of HTML attributes to add to the tag
# @return [String] the anchor tag HTML
# @raise [ArgumentError] if the file cannot be found
def link_to(text, relative_path = nil, options = {}, &block)
if block.present?
options = relative_path || {}
relative_path = text
text = view.respond_to?(:capture) ? view.capture(&block) : yield
elsif relative_path.nil?
raise ArgumentError, "You must provide a relative path"
end
segments = attributes_from_options({ href: url_for(relative_path) }.merge(options))
safe("#{text}")
end
# Create a set of attributes from a hash.
#
# @param options [Hash] key-value pairs of HTML attributes
# @return [String]
def attributes_from_options(options)
segments = []
options.each do |attr, option|
attr = dashed(attr)
if option.is_a?(Hash)
option = option.transform_keys { |key| "#{attr}-#{dashed(key)}" }
segments << attributes_from_options(option)
else
segments << attribute_segment(attr, option)
end
end
safe(segments.join(" "))
end
# Delegates to I18n#translate but also performs two additional
# functions.
#
# First, if the key starts with a period translate will scope
# the key by the current view. Calling translate(".foo") from
# the people/index.html.erb template is equivalent to calling
# translate("people.index.foo"). This makes it less
# repetitive to translate many keys within the same view and provides
# a convention to scope keys consistently.
#
# Second, the translation will be marked as html_safe if the key
# has the suffix "_html" or the last element of the key is "html". Calling
# translate("footer_html") or translate("footer.html")
# will return an HTML safe string that won't be escaped by other HTML
# helper methods. This naming convention helps to identify translations
# that include HTML tags so that you know what kind of output to expect
# when you call translate in a template and translators know which keys
# they can provide HTML values for.
#
# @return [String] the translated string
# @see I18n
def translate(key, **options) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
return key.map { |k| translate(k, **options) } if key.is_a?(Array)
key = key&.to_s
if key&.start_with?(".")
view_path = view&.page&.relative_path&.to_s&.split(".")&.first
key = "#{view_path.tr("/", ".")}#{key}" if view_path.present?
end
ActiveSupport::HtmlSafeTranslation.translate(key, **options)
end
alias_method :t, :translate
# Delegates to I18n.localize with no additional functionality.
#
# @return [String] the localized string
# @see I18n
def localize(...)
I18n.localize(...)
end
alias_method :l, :localize
# For template contexts where ActiveSupport's output safety is loaded, we
# can ensure a string has been marked safe
#
# @param input [Object]
# @return [String]
def safe(input)
input.to_s.html_safe
end
alias_method :raw, :safe
# Define a new content slot
#
# @param name [String, Symbol] name of the slot
# @param input [String] content if not supplying a block
# @param replace [Boolean] set to true to replace any previously defined slot with same name
# @param transform [Boolean] set to false to avoid template-based transforms (Markdown, etc.)
# @return [void]
def slot(name, input = nil, replace: false, transform: true, &block)
content = Bridgetown::Utils.reindent_for_markdown(
block.nil? ? input.to_s : view.capture(&block)
)
resource = if view.respond_to?(:resource)
# We're in a resource rendering context
view.resource
elsif view.respond_to?(:view_context)
# We're in a component rendering context, although it's
# likely the component's own `slot` method will be called
# in this context
view.view_context.resource
end
name = name.to_s
resource.slots.reject! { _1.name == name } if replace
resource.slots << Slot.new(
name: name,
content: content,
context: resource,
transform: transform
)
nil
end
# Render out a content slot
#
# @param name [String, Symbol] name of the slot
# @param input [String] default content if slot isn't defined and no block provided
# @return [String]
def slotted(name, default_input = nil, &default_block) # rubocop:todo Metrics
resource = if view.respond_to?(:resource)
view.resource
elsif view.respond_to?(:view_context)
view.view_context.resource
end
return unless resource
name = name.to_s
filtered_slots = resource.slots.select do |slot|
slot.name == name
end
return filtered_slots.map(&:content).join.html_safe if filtered_slots.length.positive?
default_block.nil? ? default_input.to_s : view.capture(&default_block)
end
# Check if a content slot has been defined
#
# @return [Boolean]
def slotted?(name)
resource = if view.respond_to?(:resource)
view.resource
elsif view.respond_to?(:view_context)
view.view_context.resource
end
return false unless resource
name = name.to_s
resource.slots.any? do |slot|
slot.name == name
end
end
def dsd(input = nil, &block)
tmpl_content = block.nil? ? input.to_s : view.capture(&block)
Bridgetown::Utils.dsd_tag(tmpl_content)
end
def dsd_style
tmpl_path = caller_locations(1, 2).find do |loc|
loc.label.include?("method_missing").!
end&.path
return unless tmpl_path # virtually guaranteed not to happen
tmpl_basename = File.basename(tmpl_path, ".*")
style_path = File.join(File.dirname(tmpl_path), "#{tmpl_basename}.dsd.css")
unless File.file?(style_path)
raise Bridgetown::Errors::FatalException, "Missing stylesheet at #{style_path}"
end
style_tag = site.tmp_cache["dsd_style:#{style_path}"] ||=
""
style_tag.html_safe
end
private
# Covert an underscored value into a dashed string.
#
# @example "foo_bar_baz" => "foo-bar-baz"
#
# @param value [String|Symbol]
# @return [String]
def dashed(value)
value.to_s.tr("_", "-")
end
# Create an attribute segment for a tag.
#
# @param attr [String] the HTML attribute name
# @param value [String] the attribute value
# @return [String]
def attribute_segment(attr, value)
"#{attr}=\"#{Utils.xml_escape(value)}\""
end
end
end
end