Packages

Yet another static website generator written in Elixir with MD parser (forked from serum)

Current section

Files

Jump to
serum_md lib serum plugins preview_generator.ex
Raw

lib/serum/plugins/preview_generator.ex

defmodule Serum.Plugins.PreviewGenerator do
@moduledoc """
A Serum plugin that generates preview texts of pages and blog posts.
## Using the Plugin
First, add this plugin to your `serum.exs`:
%{
plugins: [
# Use this plugin with default options.
Serum.Plugins.PreviewGenerator
# Or, use this plugin with custom options.
{Serum.Plugins.PreviewGenerator, args: options}
]
}
By default, this plugin takes the text of the first paragraph (`<p>` tag) to
generate the preview text for each page or blog post. This behavior can be
customized; see the "Configuration" section below for a list of available
options.
Generated preview texts will be saved in `:extras` map of each page or post,
with `"preview"` key. The name of key can also be customized.
<%= for post <- @all_posts do %>
<div class="post-list-item">
<!-- ... -->
<p class="preview"><%= post.extras["preview"]</p>
</div>
<% end %>
If a page or a blog post already has `extras["preview"]` (or other key
specified by the `:preview_key` option), its value will be used instead.
---
title: Sample Blog Post
date: 2020-07-17
tags: sample
preview: This string will be used instead as a preview text of this post.
---
Lorem ipsum dolor sit amet, ...
## Configuration
- `:length`
A keyword list which determines the maximum length of each preview text.
Each item of this keyword list is called a "length spec," and a length
spec should look like one of the following:
- `{:chars, max_chars}` - Limits the maximum length of preview texts to
`max_chars` characters.
- `{:words, max_words}` - Limits the maximum length of preview texts to
`max_words` words. Each word is a group of characters split by one
or more whitespace characters.
- `{:paragraphs, max_paragraphs}` - Limits the maximum length of preview
texts to `max_paragraphs` paragraphs. This option assumes that contents
of each page or blog post is well organized using `<p>` tags. Otherwise
this plugin may produce undesired results.
This keyword list can have more than one length specs. In that case, this
plugin will "sample" multiple preview texts for each page or blog post,
and it will pick the shortest one as a final preview text.
Defaults to `[paragraphs: 1]`.
- `:preview_key`
A string which will be used as an alternative key for storing generated
preview texts. For example, if you set `:preview_key` to `"summary"`, you
will be able to access the preview text in your templates like this:
<% %Serum.Post{} = post %>
<p><%= post.extras["summary"] %></p>
Defaults to `"preview"`. If any value is given, any leading or trailing
whitespaces will be trimmed.
### Configuration Example
# In your serum.exs:
%{
plugins: [
{Serum.Plugins.PreviewGenerator,
args: [
length: [paragraphs: 1, chars: 250],
preview_key: "preview_text"
]}
]
}
"""
@behaviour Serum.Plugin
alias Serum.Page
alias Serum.Post
alias Serum.Result
@default_options [length: [paragraphs: 1], preview_key: "preview"]
serum_ver = Version.parse!(Mix.Project.config()[:version])
serum_req = "~> #{serum_ver.major}.#{serum_ver.minor}"
@impl true
@spec name() :: binary()
def name, do: "Preview Text Generator"
@impl true
@spec version() :: binary()
def version, do: "1.0.0"
@impl true
@spec elixir() :: binary()
def elixir, do: "~> 1.8"
@impl true
@spec serum() :: binary()
def serum, do: unquote(serum_req)
@impl true
@spec description() :: binary()
def description, do: "Generates preview texts of your pages and blog posts."
@impl true
@spec implements :: [atom() | {atom(), integer()}]
def implements do
[
build_started: 3,
processed_pages: 2,
processed_posts: 2
]
end
#
# Optional Callback Implementations
#
@impl true
@spec build_started(binary(), binary(), keyword()) :: Result.t()
def build_started(_src, _dest, args) do
[
(args[:length] && validate_length_specs(args[:length])) || :ok,
(args[:preview_key] && validate_preview_key(args[:preview_key])) || :ok
]
|> Result.aggregate("failed to validate plugin arguments")
end
@impl true
@spec processed_pages([Page.t()], keyword()) :: Result.t([Page.t()])
def processed_pages(pages, args) do
{:ok, put_preview_texts(pages, args)}
end
@impl true
@spec processed_posts([Post.t()], keyword()) :: Result.t([Post.t()])
def processed_posts(posts, args) do
{:ok, put_preview_texts(posts, args)}
end
#
# Internal Functions
#
@spec validate_length_specs([term()]) :: Result.t()
defp validate_length_specs(value)
defp validate_length_specs([_ | _] = length_specs) do
length_specs
|> Enum.find(&(!valid_length_spec?(&1)))
|> case do
nil ->
:ok
invalid_value ->
{:error, "#{inspect(invalid_value)} is not a valid preview length spec"}
end
end
defp validate_length_specs(anything_else) do
{:error, "the value of :length must be a list, got: #{anything_else}"}
end
@spec valid_length_spec?(term()) :: boolean()
defp valid_length_spec?(value)
defp valid_length_spec?({:chars, l}) when is_integer(l) and l >= 0, do: true
defp valid_length_spec?({:words, l}) when is_integer(l) and l >= 0, do: true
defp valid_length_spec?({:paragraphs, l}) when is_integer(l) and l >= 0, do: true
defp valid_length_spec?(_anything_else), do: false
@spec validate_preview_key(term()) :: Result.t()
defp validate_preview_key(value)
defp validate_preview_key(string) when is_binary(string) do
case String.trim(string) do
"" ->
{:error, "the value of :preview_key must not be empty nor contain only whitespaces"}
_string ->
:ok
end
end
defp validate_preview_key(_anything_else) do
{:error, "the value of :preview_key must be a non-empty string"}
end
@spec put_preview_texts([content], keyword()) :: [content] when content: Page.t() | Post.t()
defp put_preview_texts(contents, args) do
args = Keyword.merge(@default_options, args || [])
length_specs = reduce_length_specs(args[:length])
preview_key = String.trim(args[:preview_key])
Enum.map(contents, &put_preview_text(&1, length_specs, preview_key))
end
@spec reduce_length_specs(keyword()) :: keyword()
defp reduce_length_specs(length_specs) do
length_specs
|> Enum.group_by(&elem(&1, 0), &elem(&1, 1))
|> Enum.map(fn {k, v} -> {k, Enum.min(v)} end)
|> Keyword.new()
end
@spec put_preview_text(content, keyword(), binary()) :: content
when content: Page.t() | Post.t()
defp put_preview_text(content, length_specs, preview_key) do
if Map.has_key?(content.extras, preview_key) do
content
else
html = get_html(content)
preview =
length_specs
|> Enum.map(&generate_preview_text(html, &1))
|> Enum.min_by(&String.length/1, fn -> "" end)
%{content | extras: Map.put(content.extras, preview_key, preview)}
end
end
@spec get_html(content) :: Floki.html_tree() when content: Page.t() | Post.t()
defp get_html(content)
defp get_html(%Page{data: data}), do: Floki.parse_document!(data)
defp get_html(%Post{html: html}), do: Floki.parse_document!(html)
@spec generate_preview_text(Floki.html_tree(), {atom(), non_neg_integer()}) :: binary()
defp generate_preview_text(html, length_spec)
defp generate_preview_text(_html, {_, l}) when l <= 0, do: ""
defp generate_preview_text(html, {:chars, l}) do
html
|> text_from_html()
|> String.slice(0, l)
end
defp generate_preview_text(html, {:words, l}) do
html
|> Floki.text(sep: " ")
|> String.split(~r/\s/, trim: true)
|> Enum.take(l)
|> Enum.join(" ")
end
defp generate_preview_text(html, {:paragraphs, l}) do
html
|> Floki.find("p")
|> Enum.take(l)
|> Enum.map_join(" ", &text_from_html/1)
end
@spec text_from_html(Floki.html_tree()) :: binary()
defp text_from_html(html) do
html
|> Floki.text(sep: " ")
|> String.replace(~r/\s+/, " ")
|> String.trim()
end
end