defmodule Curtail do @moduledoc ~S""" # An HTML-safe string truncator ## Usage Curtail.truncate("

Truncate me

", options) """ @regex ~r/(?:.*<\/script>)+|<\/?[^>]+>|[a-z0-9\|`~!@#\$%^&*\(\)\-_\+=\[\]{}:;'²³§",\.\/?]+|\s+|[[:punct:]]|\X/xiu alias Curtail.Html alias Curtail.Options @doc """ Safely truncates a string that contains HTML tags. ## Options * length (default: 100) * omission (default: "...") * word_boundary (default: "~r/\S/") * break_token (default: nil) ## Examples iex> Curtail.truncate("

Truncate me!

") "

Truncate me!

" iex> Curtail.truncate("

Truncate me!

", length: 12) "

Truncate...

" Truncate without omission string: iex> Curtail.truncate("

Truncate me!

", omission: "", length: 8) "

Truncate

" Truncate with custom word_boundary: iex> Curtail.truncate("

Truncate. Me!

", word_boundary: ~r/\S[\.]/, length: 12, omission: "") "

Truncate.

" Truncate without word boundary: iex> Curtail.truncate("

Truncate me

", word_boundary: false, length: 7) "

Trun...

" Truncate with custom break_token: iex> Curtail.truncate("

This should be truncated here!!

", break_token: "") "

This should be truncated here

" """ def truncate(string, opts \\ []) def truncate(_string, length: length) when length <= 0, do: "" def truncate(string, opts) do opts = Options.new(opts) tokens = Regex.scan(@regex, string) |> List.flatten |> Enum.map(fn(match) -> match |> String.replace(~r/\n/, " ") |> String.replace(~r/\s+/, " ") end) chars_remaining = opts.length - String.length(opts.omission) do_truncate(tokens, %Html{}, opts, chars_remaining, []) end defp do_truncate([_token|_rest], tags, opts, chars_remaining, acc) when chars_remaining <= 0 do do_truncate([], tags, opts, 0, acc) end defp do_truncate([token|_rest], tags, opts = %Options{break_token: break_token}, _, acc) when break_token == token do finalize_output(acc, tags, opts) end defp do_truncate([], tags, opts, chars_remaining, acc) when chars_remaining > 0 do finalize_output(acc, tags, opts) end defp do_truncate([], tags, opts, _, acc) do acc |> apply_omission(opts.omission) |> finalize_output(tags, opts) end defp do_truncate([token | rest], tags, opts, chars_remaining, acc) do acc = cond do Html.tag?(token) || Html.comment?(token) -> [token | acc] opts.word_boundary -> case (chars_remaining - String.length(token)) >= 0 do true ->[token | acc] false -> acc end true -> [String.slice(token, 0..chars_remaining - 1) | acc] end tags = cond do !Html.tag?(token) -> tags Html.open_tag?(token) -> %Html{tags | open_tags: [token | tags.open_tags]} true -> remove_latest_open_tag(token, tags) end chars_remaining = cond do Html.tag?(token) || Html.comment?(token) -> chars_remaining opts.word_boundary -> chars_remaining - String.length(token) true -> chars_remaining - (String.slice(token, 0..chars_remaining - 1) |> String.length) end do_truncate(rest, tags, opts, chars_remaining, acc) end defp remove_latest_open_tag(close_tag, tags = %Html{open_tags: open_tags}) do case Enum.find_index(open_tags, &(Html.matching_close_tag?(&1, close_tag))) do nil -> tags index -> %Html{tags | open_tags: List.delete_at(open_tags, index)} end end defp apply_omission([], nil), do: [""] defp apply_omission([], omission), do: [omission] defp apply_omission(tokens, omission) do tokens_with_omission = tokens |> List.first |> String.rstrip |> Kernel.<>(omission) List.replace_at(tokens, 0, tokens_with_omission) end defp apply_word_boundary(output, false), do: output defp apply_word_boundary(output, word_boundary) do {:ok, r } = Regex.compile("^.*#{Regex.source(word_boundary)}") Regex.scan(r, output) |> Enum.at(0, []) |> List.first end defp finalize_output(tokens, %Html{open_tags: open_tags}, %Options{word_boundary: word_boundary}) do closing_tags = open_tags |> Enum.map(&Html.matching_close_tag/1) |> Enum.reverse output = (closing_tags ++ tokens) |> Enum.reverse |> Enum.join case apply_word_boundary(output, word_boundary) do nil -> output match when match != output -> match <> Enum.join(closing_tags) _ -> output end end end