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 (`
` 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 %>
<%= post.extras["preview"]
` 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 %>
<%= post.extras["summary"] %>
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