defmodule Curtail do @moduledoc ~S""" # An HTML-safe string truncator ## Usage Curtail.truncate("
Truncate me
", options) """ @regex ~r/(?: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
This should be truncated here
" """ def truncate(string, opts \\ @default_opts) def truncate(_string, length: length) when length <= 0, do: "" def truncate(string, opts) do opts = Enum.into(opts, @default_opts) if opts.word_boundary === true do opts = Map.put(opts, :word_boundary, @default_word_boundary) end tokens = Regex.scan(@regex, string) |> List.flatten |> Enum.map(fn(match) -> match |> String.replace(~r/\n/, " ") |> String.replace(~r/\s+/, " ") end) length = opts.length - String.length(opts.omission) do_truncate(tokens, %{open_tags: [], close_tags: [], opts: opts}, length, []) end defp do_truncate([_token|_rest], state, chars_remaining, acc) when chars_remaining <= 0 do do_truncate([], state, 0, acc) end defp do_truncate([token|_rest], state = %{ opts: %{ break_token: break_token}}, _, acc) when break_token == token do acc |> close_remaining_tags(state) |> finalize_output end defp do_truncate([], state, chars_remaining, acc) when chars_remaining > 0 do acc |> close_remaining_tags(state) |> finalize_output end defp do_truncate([], state, _, acc) do acc |> apply_omission(state.opts.omission) |> close_remaining_tags(state) |> finalize_output end defp do_truncate([token | rest], state, chars_remaining, acc) do acc = process_token(token, chars_remaining, state.opts, acc) {state, chars_remaining} = update_state(token, state, chars_remaining) do_truncate(rest, state, chars_remaining, acc) end defp update_state(token, state, chars_remaining) do cond do Html.tag?(token) -> state = if Html.open_tag?(token) do Map.put(state, :open_tags, [token | state.open_tags]) else remove_latest_open_tag(token, state) end !Html.comment?(token) -> chars_remaining = calculate_chars_remaining(token, chars_remaining, state.opts) true -> #noop end {state, chars_remaining} end defp process_token(token, chars_remaining, opts, acc) do cond do Html.tag?(token) || Html.comment?(token) -> [token | acc] opts.word_boundary -> if (chars_remaining - String.length(token)) >= 0 do [token | acc] else acc end true -> [String.slice(token, 0..chars_remaining - 1) | acc] end end defp remove_latest_open_tag(close_tag, state = %{ open_tags: open_tags }) do case Enum.find_index(open_tags, &(Html.matching_close_tag?(&1, close_tag))) do nil -> state index -> Map.put(state, :open_tags, List.delete_at(open_tags, index)) end end defp calculate_chars_remaining(token, chars_remaining, %{word_boundary: false}) do chars_remaining - (String.slice(token, 0..chars_remaining - 1) |> String.length) end defp calculate_chars_remaining(token, chars_remaining, _) do chars_remaining - String.length(token) 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 close_remaining_tags(tokens, state = %{open_tags: open_tags}) do closing_tags = Enum.map(open_tags, &Html.matching_close_tag/1) |> Enum.reverse output = closing_tags ++ tokens |> Enum.reverse |> Enum.join state |> Map.put(:close_tags, closing_tags) |> Map.put(:output, output) end defp finalize_output(%{output: output, opts: %{word_boundary: word_boundary}, close_tags: close_tags}) do if word_boundary do {:ok, r } = Regex.compile("^.*#{Regex.source(word_boundary)}") match = Regex.scan(r, output) |> Enum.at(0, []) |> List.first end cond do match && match != output -> match <> Enum.join(close_tags) true -> output end end end