├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── curtail.ex └── curtail │ ├── html.ex │ └── options.ex ├── mix.exs ├── mix.lock └── test ├── curtail ├── html_test.exs └── options_test.exs ├── curtail_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | /doc 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | elixir: 4 | - 1.10.4 5 | otp_release: 6 | - 21.2 7 | - 22.3 8 | - 23.0 9 | 10 | dist: bionic 11 | 12 | jobs: 13 | include: 14 | - elixir: 1.4.4 15 | otp_release: 19.1 16 | dist: trusty 17 | - elixir: 1.4.4 18 | otp_release: 19.2 19 | dist: trusty 20 | - elixir: 1.5.0 21 | otp_release: 19.1 22 | dist: trusty 23 | - elixir: 1.5.3 24 | otp_release: 20.2 25 | dist: trusty 26 | - elixir: 1.6.0 27 | otp_release: 20.2 28 | dist: trusty 29 | - elixir: 1.6.4 30 | otp_release: 20.2 31 | dist: trusty 32 | - elixir: 1.6.5 33 | otp_release: 21.0 34 | dist: trusty 35 | - elixir: 1.7.0 36 | otp_release: 21.0 37 | dist: trusty 38 | - elixir: 1.7.1 39 | otp_release: 21.0 40 | dist: trusty 41 | - elixir: 1.7.2 42 | otp_release: 21.0 43 | dist: trusty 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Sean Kay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Curtail 2 | ======= 3 | [![Build Status](https://travis-ci.org/seankay/curtail.svg?branch=master)](https://travis-ci.org/seankay/curtail) 4 | 5 | HTML tag safe string truncation. 6 | 7 | Installation 8 | ===== 9 | 10 | ```elixir 11 | def deps do 12 | [ {:curtail, "~> 2.0"} ] 13 | end 14 | ``` 15 | 16 | Usage 17 | ====== 18 | 19 | ### Options 20 | * length (default: 100) 21 | * omission (default: "...") 22 | * word_boundary (default: "~r/\S/") 23 | * break_token (default: nil) 24 | 25 | Truncate using default options: 26 | ```elixir 27 | iex> Curtail.truncate("

Truncate me!

") 28 | "

Truncate me!

" 29 | 30 | ``` 31 | Truncate with custom length: 32 | ```elixir 33 | iex> Curtail.truncate("

Truncate me!

", length: 12) 34 | "

Truncate...

" 35 | ``` 36 | 37 | Truncate without omission string: 38 | ```elixir 39 | iex> Curtail.truncate("

Truncate me!

", omission: "", length: 8) 40 | "

Truncate

" 41 | ``` 42 | 43 | Truncate with custom word_boundary: 44 | ```elixir 45 | iex> Curtail.truncate("

Truncate. Me!

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

Truncate.

" 47 | ``` 48 | 49 | Truncate without word boundary: 50 | ```elixir 51 | iex> Curtail.truncate("

Truncate me

", word_boundary: false, length: 7) 52 | "

Trun...

" 53 | ``` 54 | 55 | Truncate with custom break_token: 56 | ```elixir 57 | iex> Curtail.truncate("

This should be truncated here!!

", break_token: "") 58 | "

This should be truncated here

" 59 | ``` 60 | 61 | License 62 | ======= 63 | Released under [MIT License.](http://opensource.org/licenses/MIT) 64 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for third- 9 | # party users, it should be done in your mix.exs file. 10 | 11 | # Sample configuration: 12 | # 13 | # config :logger, :console, 14 | # level: :info, 15 | # format: "$date $time [$level] $metadata$message\n", 16 | # metadata: [:user_id] 17 | 18 | # It is also possible to import configuration files, relative to this 19 | # directory. For example, you can emulate configuration per environment 20 | # by uncommenting the line below and defining dev.exs, test.exs and such. 21 | # Configuration from the imported file will override the ones defined 22 | # here (which is why it is important to import them last). 23 | # 24 | # import_config "#{Mix.env}.exs" 25 | -------------------------------------------------------------------------------- /lib/curtail.ex: -------------------------------------------------------------------------------- 1 | defmodule Curtail do 2 | @moduledoc ~S""" 3 | # An HTML-safe string truncator 4 | 5 | ## Usage 6 | Curtail.truncate("

Truncate me

", options) 7 | """ 8 | 9 | @regex ~r/(?:.*<\/script>)+|<\/?[^>]+>|[a-z0-9\|`~!@#\$%^&*\(\)\-_\+=\[\]{}:;'²³§",\.\/?]+|\s+|[[:punct:]]|\X/xiu 10 | 11 | alias Curtail.Html 12 | alias Curtail.Options 13 | 14 | @doc """ 15 | Safely truncates a string that contains HTML tags. 16 | 17 | ## Options 18 | 19 | * length (default: 100) 20 | * omission (default: "...") 21 | * word_boundary (default: "~r/\S/") 22 | * break_token (default: nil) 23 | 24 | ## Examples 25 | 26 | iex> Curtail.truncate("

Truncate me!

") 27 | "

Truncate me!

" 28 | 29 | iex> Curtail.truncate("

Truncate me!

", length: 12) 30 | "

Truncate...

" 31 | 32 | Truncate without omission string: 33 | 34 | iex> Curtail.truncate("

Truncate me!

", omission: "", length: 8) 35 | "

Truncate

" 36 | 37 | Truncate with custom word_boundary: 38 | 39 | iex> Curtail.truncate("

Truncate. Me!

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

Truncate.

" 41 | 42 | Truncate without word boundary: 43 | 44 | iex> Curtail.truncate("

Truncate me

", word_boundary: false, length: 7) 45 | "

Trun...

" 46 | 47 | Truncate with custom break_token: 48 | 49 | iex> Curtail.truncate("

This should be truncated here!!

", length: 49, break_token: "") 50 | "

This should be truncated here

" 51 | """ 52 | def truncate(string, opts \\ []) 53 | def truncate(_string, length: length) when length <= 0, do: "" 54 | def truncate(string, opts) do 55 | opts = Options.new(opts) 56 | 57 | tokens = Regex.scan(@regex, string) 58 | |> List.flatten 59 | |> Enum.map(fn(match) -> 60 | match 61 | |> String.replace(~r/\n/, " ") 62 | |> String.replace(~r/\s+/, " ") 63 | end) 64 | 65 | chars_remaining = opts.length - String.length(opts.omission) 66 | 67 | case String.length(string) - opts.length do 68 | x when x <= 0 -> string 69 | _ -> do_truncate(tokens, %Html{}, opts, chars_remaining, []) 70 | end 71 | end 72 | 73 | defp do_truncate([_token|_rest], tags, opts, chars_remaining, acc) when chars_remaining <= 0 do 74 | do_truncate([], tags, opts, 0, acc) 75 | end 76 | 77 | defp do_truncate([token|_rest], tags, opts = %Options{break_token: break_token}, _, acc) 78 | when break_token == token do 79 | finalize_output(acc, tags, opts) 80 | end 81 | 82 | defp do_truncate([], tags, opts, chars_remaining, acc) when chars_remaining > 0 do 83 | finalize_output(acc, tags, opts) 84 | end 85 | 86 | defp do_truncate([], tags, opts, _, acc) do 87 | acc |> apply_omission(opts.omission) |> finalize_output(tags, opts) 88 | end 89 | 90 | defp do_truncate([token | rest], tags, opts, chars_remaining, acc) do 91 | acc = cond do 92 | Html.tag?(token) || Html.comment?(token) -> 93 | [token | acc] 94 | opts.word_boundary -> 95 | case (chars_remaining - String.length(token)) >= 0 do 96 | true ->[token | acc] 97 | false -> acc 98 | end 99 | true -> 100 | [String.slice(token, 0..chars_remaining - 1) | acc] 101 | end 102 | 103 | tags = cond do 104 | !Html.tag?(token) -> 105 | tags 106 | Html.open_tag?(token) -> 107 | %Html{tags | open_tags: [token | tags.open_tags]} 108 | true -> 109 | remove_latest_open_tag(token, tags) 110 | end 111 | 112 | chars_remaining = cond do 113 | Html.tag?(token) || Html.comment?(token) -> 114 | chars_remaining 115 | opts.word_boundary -> 116 | chars_remaining - String.length(token) 117 | true -> 118 | chars_remaining - (String.slice(token, 0..chars_remaining - 1) |> String.length) 119 | end 120 | 121 | do_truncate(rest, tags, opts, chars_remaining, acc) 122 | end 123 | 124 | defp remove_latest_open_tag(close_tag, tags = %Html{open_tags: open_tags}) do 125 | case Enum.find_index(open_tags, &(Html.matching_close_tag?(&1, close_tag))) do 126 | nil -> tags 127 | index -> %Html{tags | open_tags: List.delete_at(open_tags, index)} 128 | end 129 | end 130 | 131 | defp apply_omission([], nil), do: [""] 132 | defp apply_omission([], omission), do: [omission] 133 | defp apply_omission(tokens, omission) do 134 | tokens_with_omission = tokens 135 | |> List.first 136 | |> String.trim_trailing() 137 | |> Kernel.<>(omission) 138 | 139 | List.replace_at(tokens, 0, tokens_with_omission) 140 | end 141 | 142 | defp apply_word_boundary(output, false), do: output 143 | defp apply_word_boundary(output, word_boundary) do 144 | {:ok, r } = Regex.compile("^.*#{Regex.source(word_boundary)}") 145 | Regex.scan(r, output) |> Enum.at(0, []) |> List.first 146 | end 147 | 148 | defp finalize_output(tokens, %Html{open_tags: open_tags}, %Options{word_boundary: word_boundary}) do 149 | closing_tags = open_tags 150 | |> Enum.map(&Html.matching_close_tag/1) 151 | |> Enum.reverse 152 | 153 | output = (closing_tags ++ tokens) |> Enum.reverse |> Enum.join 154 | 155 | case apply_word_boundary(output, word_boundary) do 156 | nil -> output 157 | match when match != output -> match <> Enum.join(closing_tags) 158 | _ -> output 159 | end 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /lib/curtail/html.ex: -------------------------------------------------------------------------------- 1 | defmodule Curtail.Html do 2 | @moduledoc """ 3 | Helper methods for `Curtail`. 4 | """ 5 | 6 | defstruct open_tags: [], 7 | close_tags: [] 8 | 9 | def tag?(token), do: Regex.match?(~r/<\/?[^>]+>/, token) && !comment?(token) 10 | 11 | def open_tag?(token), do: Regex.match?(~r/<(?!(?:br|img|hr|script|\/))[^>]+>/i, token) 12 | 13 | def comment?(token), do: Regex.match?(~r/(*ANY)<\s?!--.*-->/, token) 14 | 15 | def matching_close_tag(token) do 16 | Regex.replace(~r/<(\w+)\s?.*>/, token, "") |> String.trim() 17 | end 18 | 19 | def matching_close_tag?(open_tag, close_tag) do 20 | matching_close_tag(open_tag) == close_tag 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/curtail/options.ex: -------------------------------------------------------------------------------- 1 | defmodule Curtail.Options do 2 | 3 | @default_word_boundary ~r/\S/ 4 | 5 | defstruct length: 100, 6 | omission: "...", 7 | word_boundary: @default_word_boundary, 8 | break_token: nil 9 | 10 | alias __MODULE__ 11 | 12 | def new(opts\\ []) do 13 | configure(struct(Options, opts)) 14 | end 15 | 16 | defp configure(opts = %Options{ word_boundary: true}) do 17 | %Options{opts | word_boundary: @default_word_boundary} 18 | end 19 | defp configure(opts), do: opts 20 | end 21 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Curtail.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/seankay/curtail" 5 | 6 | def project do 7 | [app: :curtail, 8 | version: "2.0.0", 9 | elixir: ">= 1.3.0", 10 | description: description(), 11 | package: package(), 12 | deps: deps(), 13 | name: "Curtail", 14 | source_url: @source_url 15 | ] 16 | end 17 | 18 | def application do 19 | [applications: [:logger]] 20 | end 21 | 22 | defp deps do 23 | [{:earmark, "~> 1.0", only: :dev}, 24 | {:ex_doc, "~> 0.19", only: :dev}] 25 | end 26 | 27 | defp description do 28 | "HTML-safe string truncation." 29 | end 30 | 31 | defp package do 32 | [ 33 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 34 | contributors: ["Sean Kay"], 35 | licenses: ["Apache 2.0"], 36 | links: %{"GitHub" => @source_url} 37 | ] 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.3.0", "17f0c38eaafb4800f746b457313af4b2442a8c2405b49c645768680f900be603", [:mix], [], "hexpm", "f8b8820099caf0d5e72ae6482d2b0da96f213cbbe2b5b2191a37966e119eaa27"}, 3 | "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "dc87f778d8260da0189a622f62790f6202af72f2f3dee6e78d91a18dd2fcd137"}, 4 | "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d7152ff93f2eac07905f510dfa03397134345ba4673a00fbf7119bab98632940"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "4a36dd2d0d5c5f98d95b3f410d7071cd661d5af310472229dd0e92161f168a44"}, 6 | "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], [], "hexpm", "ebb595e19456a72786db6dcd370d320350cb624f0b6203fcc7e23161d49b0ffb"}, 7 | } 8 | -------------------------------------------------------------------------------- /test/curtail/html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Curtail.HtmlTagTest do 2 | use ExUnit.Case 3 | import Curtail.Html 4 | 5 | test "html tag" do 6 | assert tag?("

") == true 7 | assert tag?("

") == true 8 | assert tag?("") == false 9 | end 10 | 11 | test "html comment" do 12 | assert comment?("") == true 13 | assert comment?("

") == false 14 | end 15 | 16 | test "open tag" do 17 | assert open_tag?("

") == true 18 | assert open_tag?("

") == false 19 | assert open_tag?("
") == false 20 | assert open_tag?("") == false 21 | assert open_tag?("
") == false 22 | end 23 | 24 | test "finds matching close tag" do 25 | assert matching_close_tag("

") == "

" 26 | end 27 | 28 | test "checking matching close tag" do 29 | assert matching_close_tag?("

", "

") == true 30 | assert matching_close_tag?("

", "") == false 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/curtail/options_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Curtail.OptionsTest do 2 | use ExUnit.Case 3 | 4 | alias Curtail.Options 5 | 6 | test "creating options" do 7 | assert Options.new == %Options{} 8 | end 9 | 10 | test "uses default word_boundary when word_boundary is `true`" do 11 | assert Options.new([word_boundary: true]).word_boundary == ~r/\S/ 12 | end 13 | 14 | test "overriding default options" do 15 | assert Options.new(omission: "!").omission == "!" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/curtail_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CurtailTest do 2 | use ExUnit.Case 3 | import Curtail 4 | doctest Curtail 5 | 6 | test "properly uses default options when option is not passed in" do 7 | string = "

this is a test!

" 8 | 9 | assert truncate(string) == string 10 | end 11 | 12 | test "return empty string when length provided is less than or equal to 0" do 13 | string = "test" 14 | result = "" 15 | 16 | assert truncate(string, length: 0) == result 17 | assert truncate(string, length: -1) == result 18 | end 19 | 20 | test "truncates string based on length" do 21 | string = "

This link is a test link

" 22 | result = "

This ...

" 23 | 24 | assert truncate(string, length: 10) == result 25 | end 26 | 27 | test "correctly truncates string with html comments" do 28 | string = "

hello and goodbye

" 29 | result = "

hello and ...

" 30 | 31 | assert truncate(string, length: 15) == result 32 | end 33 | 34 | test "uses overriden omission" do 35 | string = "

This is a test

" 36 | result = "

This is a.

" 37 | 38 | assert truncate(string, length: 10, omission: ".") == result 39 | end 40 | 41 | test "truncates to the exact length specified when word_boundary is set to false" do 42 | html = "
123456789
" 43 | result = "
12345
" 44 | 45 | assert truncate(html, length: 5, omission: "", word_boundary: false) == result 46 | end 47 | 48 | test "retains the tags within the text when word_boundary is set to false" do 49 | html = "some text CAPS some text" 50 | result = "some text CAPS some te..." 51 | 52 | assert truncate(html, length: 25, word_boundary: false) == result 53 | end 54 | 55 | test "retains the omission text when word_boundary is false" do 56 | html = "testtest" 57 | result = "testt.." 58 | 59 | assert truncate(html, length: 7, omission: "..", word_boundary: false) == result 60 | end 61 | 62 | test "handles multibyte characters when word_boundary is false" do 63 | html = "prüfenprüfen" 64 | result = "prüfen.." 65 | 66 | assert truncate(html, length: 8, omission: "..", word_boundary: false) == result 67 | end 68 | 69 | test "truncates using the default word_boundary option when word_boundary is true" do 70 | html = "hello there. or maybe not?" 71 | result = "hello there. or" 72 | 73 | assert truncate(html, length: 16, omission: "", word_boundary: true) == result 74 | end 75 | 76 | test "truncates to the end of the nearest sentence when word_boundary is custom" do 77 | html = "hello there. or maybe not?" 78 | result = "hello there." 79 | 80 | assert truncate(html, length: 16, omission: "", word_boundary: ~r/\S[\.\?\!]/) == result 81 | end 82 | 83 | test "is respectful of closing tags when word_boundary is custom" do 84 | html = "

hmmm this should be okay. I think...

" 85 | result = "

hmmm this should be okay.

" 86 | 87 | assert truncate(html, length: 28, omission: "", word_boundary: ~r/\S[\.\?\!]/) == result 88 | end 89 | 90 | test "includes the omission text's length in the returned truncated html" do 91 | html = "a b c" 92 | result = "a..." 93 | 94 | assert truncate(html, length: 4, omission: "...") == result 95 | end 96 | 97 | test "includes omission even on the edge" do 98 | result = "One two t..." 99 | html = "One two three" 100 | 101 | assert truncate(html, word_boundary: false, length: 12) == result 102 | end 103 | 104 | test "never returns a string longer than length" do 105 | assert truncate("test this stuff", length: 10) == "test..." 106 | end 107 | 108 | test "returns the omission when the specified length is smaller than the omission" do 109 | assert truncate("a b c", length: 2, omission: "...") == "..." 110 | end 111 | 112 | test "treats script tags as strings with no length" do 113 | html = "

I have a script and more text

" 114 | result = "

I have a script and...

" 115 | assert truncate(html, length: 23) == result 116 | end 117 | 118 | test "in the middle of a link, truncates and closes the , and closes any remaining open tags" do 119 | html = "
" 120 | result = "
" 121 | assert truncate(html, length: 15) == result 122 | end 123 | 124 | test "places the punctuation after the tag without any whitespace when character is after closing tag" do 125 | punctuations = ["!", "@","#","$","%","^","&","*","\(","\)","-","_","+", 126 | "=","[","]","{","}","\\","|",",",".","/","?"] 127 | 128 | Enum.each(punctuations, fn(char) -> 129 | html = "

Look at this#{char} More words here

" 130 | result = "

Look at this#{char}...

" 131 | assert truncate(html, length: 19) == result 132 | end) 133 | end 134 | 135 | test "leaves a whitespace between the closing tag and the following word character when html has non-punctuation char after closing tag" do 136 | html = "

Look at this link for randomness

" 137 | result = "

Look at this link...

" 138 | assert truncate(html, length: 21) == result 139 | end 140 | 141 | test "handles multibyte characters and leaves them in the result" do 142 | html = "

Look at our multibyte characters ā ž this link for randomness ā ž

" 143 | assert truncate(html, length: String.length(html)) == html 144 | end 145 | 146 | test "recognizes the multiline html properly" do 147 | html = "
149 | This is ugly html. 150 |
" 151 | result = "
This is...
" 152 | assert truncate(html, length: 12) == result 153 | end 154 | 155 | test "if html contains unpaired tag and unpaired tag does not have closing slash it does not close the unpaired tag" do 156 | unpaired_tags = ["br", "hr", "img"] 157 | 158 | Enum.each(unpaired_tags, fn(unpaired_tag) -> 159 | html = "
Some before. <#{unpaired_tag}>and some after
" 160 | html_caps = "
Some before. <#{unpaired_tag}>and some after
" 161 | assert truncate(html, length: 19) == "
Some before. <#{unpaired_tag}>and...
" 162 | assert truncate(html_caps, length: 19) == "
Some before. <#{unpaired_tag}>and...
" 163 | end) 164 | end 165 | 166 | test "if html contains unpaired tag and unpaired tag does have closing slash it does not close the unpaired tag" do 167 | unpaired_tags = ["br", "hr", "img"] 168 | Enum.each(unpaired_tags, fn(unpaired_tag) -> 169 | html = "
Some before. <#{unpaired_tag} />and some after
" 170 | html_caps = "
Some before. <#{unpaired_tag} />and some after
" 171 | assert truncate(html, length: 19) == "
Some before. <#{unpaired_tag} />and...
" 172 | assert truncate(html_caps, length: 19) == "
Some before. <#{unpaired_tag} />and...
" 173 | end) 174 | end 175 | 176 | test "does not truncate quotes off when input contains chinese characters" do 177 | html = "

“我现在使用的是中文的拼音。”
178 | 测试一下具体的truncatehtml功能。
179 | “我现在使用的是中文的拼音。”
180 | 测试一下具体的truncate
html功能。
181 | “我现在使用的是中文的拼音。”
182 | 测试一下具体的truncatehtml功能。
183 | “我现在使用的是中文的拼音。”
184 | 测试一下具体的truncate
html功能。

" 185 | 186 | result = truncate(html, omission: "", length: 50) 187 | assert String.contains?(result, "

“我现在使用的是中文的拼音。”
") == true 188 | end 189 | 190 | test "does not truncate abnormally if the break_token is not present" do 191 | assert truncate("This is line one. This is line two.", length: 30, break_token: "foobar") == "This is line one. This is..." 192 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one. This is..." 193 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one. This is..." 194 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one. This is..." 195 | end 196 | 197 | test "does not truncate abnormally if the break_token is present, but beyond the length param" do 198 | assert truncate("This is line one. This is line foobar two.", length: 30, break_token: "foobar") == "This is line one. This is..." 199 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one. This is..." 200 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one. This is..." 201 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one. This is..." 202 | end 203 | 204 | test "truncates before the length param if the break_token is before the token at length" do 205 | assert truncate("This is line one. foobar This is line two.", length: 30, break_token: "foobar") == "This is line one." 206 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one." 207 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one." 208 | assert truncate("This is line one. This is line two.", length: 30, break_token: "") == "This is line one." 209 | end 210 | 211 | test "does not duplicate comments" do 212 | string = "

hello and goodbye

" 213 | result = "

hello and ...

" 214 | assert truncate(string, length: 15) == result 215 | end 216 | 217 | test "only applies omission when truncation necessary" do 218 | string = "no truncation" 219 | assert truncate(string, length: String.length(string) + 1) == string 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------