lib/middleman-blog/blog_article.rb
# frozen_string_literal: true
require 'active_support/time_with_zone'
require 'active_support/core_ext/time/acts_like'
require 'active_support/core_ext/time/calculations'
module Middleman
module Blog
##
# A module that adds blog-article-specific methods to Resources. A
# {BlogArticle} can be retrieved via {Blog::Helpers#current_article} or
# methods on {BlogData} (like {BlogData#articles}).
#
# @see http://rdoc.info/github/middleman/middleman/Middleman/Sitemap/Resource Middleman::Sitemap::Resource
##
module BlogArticle
extend Gem::Deprecate
##
#
##
def self.extended(base)
base.class.send(:attr_accessor, :blog_controller)
end
##
# A reference to the {BlogData} for this article's blog.
#
# @return [BlogData]
##
def blog_data
blog_controller.data
end
##
# The options for this article's blog.
#
# @return [ConfigurationManager]
###
def blog_options
blog_controller.options
end
##
# Render this resource to a string with the appropriate layout.
# Called automatically by Middleman.
#
# @return [String]
##
def render(opts = {}, locs = {}, &block)
unless opts.key?(:layout)
opts[:layout] = metadata[:options][:layout]
opts[:layout] = blog_options.layout if opts[:layout].nil? || opts[:layout] == :_auto_layout
# Convert to a string unless it's a boolean
opts[:layout] = opts[:layout].to_s if opts[:layout].is_a? Symbol
end
content = super(opts, locs, &block)
content.sub!(blog_options.summary_separator, '') unless opts[:keep_separator]
content
end
##
# The title of the article, set from frontmatter.
#
# @return [String]
##
def title
data['title'].to_s
end
##
# Whether or not this article has been published.
# An article is considered published in the following scenarios:
#
# 1. Frontmatter does not set +published+ to false and either
# 2. The blog option +publish_future_dated+ is true or
# 3. The article's date is after the current time
#
# @return [Boolean]
##
def published?
data['published'] != false && (blog_options.publish_future_dated || date <= Time.current)
end
##
# The body of this article, in HTML (no layout). This is for things like
# RSS feeds or lists of articles - individual articles will automatically
# be rendered from their template.
#
# @return [String]
##
def body
render layout: false
end
##
# The summary for this article, in HTML.
#
# The blog option +summary_generator+ can be set to a +Proc+ in order to
# provide custom summary generation. The +Proc+ is provided the rendered
# content of the article (without layout), the desired length to trim the
# summary to, and the ellipsis string to use. Otherwise the
# {#default_summary_generator} will be used, which returns either
# everything before the summary separator (set via the blog option
# +summary_separator+ and defaulting to "READMORE") if it is found, or the
# first +summary_length+ characters of the post.
#
# @param [Number] length How many characters to trim the summary to.
# @param [String] ellipsis The ellipsis string to use when content is trimmed.
# @return [String]
##
def summary(length = nil, ellipsis = '...')
rendered = render layout: false, keep_separator: true
if blog_options.summary_generator
blog_options.summary_generator.call(self, rendered, length, ellipsis)
else
default_summary_generator(rendered, length, ellipsis)
end
end
##
# The default summary generator first tries to find the +summary_separator+ and
# take the text before it. If that doesn't work, it will truncate text without splitting
# the middle of an HTML tag, using a Nokogiri-based {TruncateHTML} utility.
#
# @param [String] rendered The rendered blog article
# @param [Integer] length The length in characters to truncate to.
# -1 or +nil+ will return the whole article.
# @param [String] ellipsis The ellipsis string to use when content is trimmed.
##
def default_summary_generator(rendered, length, ellipsis)
if blog_options.summary_separator && rendered.match(blog_options.summary_separator)
require 'middleman-blog/truncate_html'
TruncateHTML.truncate_at_separator(rendered, blog_options.summary_separator)
elsif length && length >= 0
require 'middleman-blog/truncate_html'
TruncateHTML.truncate_at_length(rendered, length, ellipsis)
elsif blog_options.summary_length&.positive?
require 'middleman-blog/truncate_html'
TruncateHTML.truncate_at_length(rendered, blog_options.summary_length, ellipsis)
else
rendered
end
end
##
# A list of tags for this article, set from frontmatter.
#
# @return [Array<String>] (never +nil+)
##
def tags
article_tags = data['tags']
if article_tags.is_a? String
article_tags.split(',').map(&:strip)
else
Array(article_tags).map(&:to_s)
end
end
##
# The language of the article. The language can be present in the
# frontmatter or in the source path. If both are present, they
# must match. If neither specifies a lang, I18n's default_locale will
# be used. If +lang+ is set to nil, or the +:i18n+ extension is not
# activated at all, +nil+ will be returned.
#
# @return [Symbol] Language code (for example, +:en+ or +:de+)
##
def locale
frontmatter_locale = data['locale'] || data['lang']
filename_locale = path_part('locale') || path_part('lang')
raise "The locale in #{path}'s filename (#{filename_locale.inspect}) doesn't match the lang in its frontmatter (#{frontmatter_locale.inspect})" if frontmatter_locale && filename_locale && frontmatter_locale != filename_locale
default_locale = I18n.default_locale if defined? ::I18n
found_locale = frontmatter_locale || filename_locale || default_locale
found_locale&.to_sym
end
alias lang locale
##
# Attempt to figure out the date of the post. The date should be
# present in the source path, but users may also provide a date
# in the frontmatter in order to provide a time of day for sorting
# reasons.
#
# @return [TimeWithZone]
##
def date
return @_date if @_date
frontmatter_date = data['date']
# First get the date from frontmatter
@_date = if frontmatter_date.is_a? Time
frontmatter_date.in_time_zone
else
Time.zone.parse(frontmatter_date.to_s)
end
# Next figure out the date from the filename
source_vars = blog_data.source_template.variables
if source_vars.include?('year') &&
source_vars.include?('month') &&
source_vars.include?('day')
filename_date = Time.zone.local(path_part('year').to_i, path_part('month').to_i, path_part('day').to_i)
if @_date
raise "The date in #{path}'s filename doesn't match the date in its frontmatter" unless @_date.to_date == filename_date.to_date
else
@_date = filename_date.to_time.in_time_zone
end
end
raise "Blog post #{path} needs a date in its filename or frontmatter" unless @_date
@_date
end
##
# The "slug" of the article that shows up in its URL. The article slug
# is a parametrized version of the {#title} (lowercase, spaces replaced
# with dashes, etc) and can be used in the blog +permalink+ as +:title+.
#
# @return [String]
##
def slug
if data['slug']
Blog::UriTemplates.safe_parameterize(data['slug'])
elsif blog_data.source_template.variables.include?('title')
Blog::UriTemplates.safe_parameterize(path_part('title'))
elsif title
Blog::UriTemplates.safe_parameterize(title)
else
raise "Can't generate a slug for #{path} because it has no :title in its path pattern or title/slug in its frontmatter."
end
end
##
# The previous (chronologically earlier) article before this one or
# +nil+ if this is the first article.
#
# @deprecated Use {#article_previous} instead.
#
# @return [BlogArticle]
##
def previous_article
article_previous
end
deprecate :previous_article, :article_previous, 2017, 5
##
# The next (chronologically later) article after this one or +nil+ if
# this is the most recent article.
#
# @deprecated Use {#article_next} instead.
#
# @return [Middleman::Sitemap::Resource]
##
def next_article
article_next
end
deprecate :next_article, :article_next, 2017, 5
##
# The previous (chronologically earlier) article before this one or
# +nil+ if this is the first article.
#
# @return [BlogArticle]
##
def article_previous
blog_data.articles.find { |a| a.date < date }
end
##
# The next (chronologically later) article after this one or +nil+ if
# this is the most recent article.
#
# @return [Middleman::Sitemap::Resource]
##
def article_next
blog_data.articles.reverse.find { |a| a.date > date }
end
##
# The previous (chronologically earlier) article before this one in the
# current locale, or +nil+ if this is the first article.
#
# @return [BlogArticle]
##
def article_locale_previous
blog_data.local_articles.find { |a| a.date < date }
end
##
# The next (chronologically later) article after this one in the current
# locale or +nil+ if this is the most recent article.
#
# @return [Middleman::Sitemap::Resource]
##
def article_locale_next
blog_data.local_articles.reverse.find { |a| a.date > date }
end
##
# This is here to prevent out-of-memory on exceptions.
#
# @private
##
def inspect
"#<Middleman::Blog::BlogArticle: #{data.inspect}>"
end
private
##
# Retrieve a section of the source path template.
#
# @param [String] part The part of the path, e.g. "lang", "year", "month", "day", "title"
# @return [String]
##
def path_part(part)
@_path_parts ||= Blog::UriTemplates.extract_params(blog_data.source_template, path)
@_path_parts[part.to_s]
end
end
end
end