├── test ├── test_helper.exs ├── phoenix_html_helpers_test.exs └── phoenix_html_helpers │ ├── csrf_test.exs │ ├── format_test.exs │ ├── link_test.exs │ ├── inputs_for_test.exs │ ├── tag_test.exs │ └── form_test.exs ├── .formatter.exs ├── .gitignore ├── lib ├── phoenix_html_helpers.ex └── phoenix_html_helpers │ ├── format.ex │ ├── form_data.ex │ ├── link.ex │ ├── tag.ex │ └── form.ex ├── LICENSE ├── mix.exs ├── .github └── workflows │ └── ci.yml ├── README.md └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/phoenix_html_helpers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpersTest do 2 | use ExUnit.Case, async: true 3 | 4 | # Just assert it can be used 5 | use PhoenixHTMLHelpers 6 | end 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | phoenix_html_helpers-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /lib/phoenix_html_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers do 2 | @moduledoc """ 3 | Collection of helpers to generate and manipulate HTML contents. 4 | 5 | These helpers were used in Phoenix v1.6 and earlier versions, 6 | before the introduction of `Phoenix.Component`. 7 | 8 | Replace `use Phoenix.HTML` in your applications by: 9 | 10 | ```elixir 11 | import Phoenix.HTML 12 | import Phoenix.HTML.Form 13 | use PhoenixHTMLHelpers 14 | ``` 15 | 16 | To preserve backwards compatibility. 17 | """ 18 | 19 | @doc false 20 | defmacro __using__(_) do 21 | quote do 22 | import PhoenixHTMLHelpers.Form 23 | import PhoenixHTMLHelpers.Link 24 | import PhoenixHTMLHelpers.Tag 25 | import PhoenixHTMLHelpers.Format 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Chris McCord 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/phoenixframework/phoenix_html_helpers" 5 | @version "1.0.0" 6 | 7 | def project do 8 | [ 9 | app: :phoenix_html_helpers, 10 | version: @version, 11 | elixir: "~> 1.7", 12 | deps: deps(), 13 | name: "PhoenixHTMLHelpers", 14 | description: "Collection of helpers to generate and manipulate HTML contents", 15 | package: package(), 16 | docs: [ 17 | source_url: @source_url, 18 | source_ref: "v#{@version}", 19 | main: "PhoenixHTMLHelpers" 20 | ] 21 | ] 22 | end 23 | 24 | def application do 25 | [ 26 | extra_applications: [:logger], 27 | env: [csrf_token_reader: {Plug.CSRFProtection, :get_csrf_token_for, []}] 28 | ] 29 | end 30 | 31 | defp deps do 32 | [ 33 | {:phoenix_html, "~> 4.0"}, 34 | {:plug, "~> 1.5", optional: true}, 35 | {:ex_doc, ">= 0.0.0", only: :docs} 36 | ] 37 | end 38 | 39 | defp package do 40 | [ 41 | maintainers: ["Chris McCord", "José Valim"], 42 | licenses: ["MIT"], 43 | links: %{GitHub: @source_url} 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/phoenix_html_helpers/csrf_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers.CSRFTest do 2 | use ExUnit.Case, async: false 3 | 4 | import Phoenix.HTML 5 | import PhoenixHTMLHelpers.Link 6 | import PhoenixHTMLHelpers.Tag 7 | 8 | test "link with post using a custom csrf token" do 9 | assert safe_to_string(link("hello", to: "/world", method: :post)) =~ 10 | ~r(hello) 11 | end 12 | 13 | test "link with put/delete using a custom csrf token" do 14 | assert safe_to_string(link("hello", to: "/world", method: :put)) =~ 15 | ~r(hello) 16 | end 17 | 18 | test "button with post using a custom csrf token" do 19 | assert safe_to_string(button("hello", to: "/world")) =~ 20 | ~r() 21 | end 22 | 23 | test "form_tag for post using a custom csrf token" do 24 | assert safe_to_string(form_tag("/")) =~ ~r( 25 | 26 | 27 | )mx 28 | end 29 | 30 | test "form_tag for other method using a custom csrf token" do 31 | assert safe_to_string(form_tag("/", method: :put)) =~ ~r( 32 | 33 | 34 | 35 | )mx 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | tests: 11 | name: Run tests (Elixir ${{matrix.elixir}}, OTP ${{matrix.otp}}) 12 | 13 | strategy: 14 | matrix: 15 | include: 16 | - elixir: 1.7 17 | otp: 21.3 18 | - elixir: 1.14 19 | otp: 25.3 20 | lint: lint 21 | 22 | runs-on: ubuntu-20.04 23 | 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | 28 | - name: Set up Elixir 29 | uses: erlef/setup-elixir@v1 30 | with: 31 | elixir-version: ${{ matrix.elixir }} 32 | otp-version: ${{ matrix.otp }} 33 | 34 | - name: Restore deps and _build cache 35 | uses: actions/cache@v2 36 | with: 37 | path: | 38 | deps 39 | _build 40 | key: deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 41 | restore-keys: | 42 | deps-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }} 43 | 44 | - name: Install dependencies 45 | run: mix deps.get --only test 46 | 47 | - name: Check source code format 48 | run: mix format --check-formatted 49 | if: ${{ matrix.lint }} 50 | 51 | - name: Remove compiled application files 52 | run: mix clean 53 | 54 | - name: Compile dependencies 55 | run: mix compile 56 | if: ${{ !matrix.lint }} 57 | env: 58 | MIX_ENV: test 59 | 60 | - name: Compile & lint dependencies 61 | run: mix compile --warnings-as-errors 62 | if: ${{ matrix.lint }} 63 | env: 64 | MIX_ENV: test 65 | 66 | - name: Run tests 67 | run: mix test 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PhoenixHTMLHelpers 2 | 3 | [![Build Status](https://github.com/phoenixframework/phoenix_html_helpers/workflows/Tests/badge.svg)](https://github.com/phoenixframework/phoenix_html_helpers/actions?query=workflow%3ATests) 4 | 5 | Collection of helpers to generate and manipulate HTML contents. 6 | These helpers were used in Phoenix v1.6 and earlier versions, 7 | before the introduction of `Phoenix.Component`. 8 | 9 | To maintain compatibility, replace `use Phoenix.HTML` in your applications by: 10 | 11 | ```elixir 12 | import Phoenix.HTML 13 | import Phoenix.HTML.Form 14 | use PhoenixHTMLHelpers 15 | ``` 16 | 17 | See the [docs](https://hexdocs.pm/phoenix_html_helpers/) for more information. 18 | 19 | This library is maintained for compatibility, but does not accept new features. 20 | 21 | ## License 22 | 23 | Copyright (c) 2014 Chris McCord 24 | 25 | Permission is hereby granted, free of charge, to any person obtaining 26 | a copy of this software and associated documentation files (the 27 | "Software"), to deal in the Software without restriction, including 28 | without limitation the rights to use, copy, modify, merge, publish, 29 | distribute, sublicense, and/or sell copies of the Software, and to 30 | permit persons to whom the Software is furnished to do so, subject to 31 | the following conditions: 32 | 33 | The above copyright notice and this permission notice shall be 34 | included in all copies or substantial portions of the Software. 35 | 36 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 37 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 38 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 39 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 40 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 41 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 42 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 43 | -------------------------------------------------------------------------------- /test/phoenix_html_helpers/format_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers.FormatTest do 2 | use ExUnit.Case, async: true 3 | 4 | import PhoenixHTMLHelpers.Format 5 | import Phoenix.HTML 6 | 7 | doctest PhoenixHTMLHelpers.Format 8 | 9 | test "wraps paragraphs" do 10 | formatted = 11 | format(""" 12 | Hello, 13 | 14 | Please come see me. 15 | 16 | Regards, 17 | The Boss. 18 | """) 19 | 20 | assert formatted == """ 21 |

Hello,

22 |

Please come see me.

23 |

Regards,
24 | The Boss.

25 | """ 26 | end 27 | 28 | test "wraps paragraphs with carriage returns" do 29 | formatted = format("Hello,\r\n\r\nPlease come see me.\r\n\r\nRegards,\r\nThe Boss.") 30 | 31 | assert formatted == """ 32 |

Hello,

33 |

Please come see me.

34 |

Regards,
35 | The Boss.

36 | """ 37 | end 38 | 39 | test "escapes html" do 40 | formatted = 41 | format(""" 42 | 43 | """) 44 | 45 | assert formatted == """ 46 |

<script></script>

47 | """ 48 | end 49 | 50 | test "skips escaping html" do 51 | formatted = 52 | format( 53 | """ 54 | 55 | """, 56 | escape: false 57 | ) 58 | 59 | assert formatted == """ 60 |

61 | """ 62 | end 63 | 64 | test "adds brs" do 65 | formatted = 66 | format(""" 67 | Hello, 68 | This is dog, 69 | How can I help you? 70 | 71 | 72 | """) 73 | 74 | assert formatted == """ 75 |

Hello,
76 | This is dog,
77 | How can I help you?

78 | """ 79 | end 80 | 81 | test "adds brs with carriage return" do 82 | formatted = format("Hello,\r\nThis is dog,\r\nHow can I help you?\r\n\r\n\r\n") 83 | 84 | assert formatted == """ 85 |

Hello,
86 | This is dog,
87 | How can I help you?

88 | """ 89 | end 90 | 91 | test "doesn't add brs" do 92 | formatted = 93 | format( 94 | """ 95 | Hello, 96 | This is dog, 97 | How can I help you? 98 | 99 | 100 | """, 101 | insert_brs: false 102 | ) 103 | 104 | assert formatted == """ 105 |

Hello, This is dog, How can I help you?

106 | """ 107 | end 108 | 109 | defp format(text, opts \\ []) do 110 | text |> text_to_html(opts) |> safe_to_string 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/phoenix_html_helpers/format.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers.Format do 2 | @moduledoc """ 3 | Formatting functions. 4 | """ 5 | 6 | @doc ~S""" 7 | Returns text transformed into HTML using simple formatting rules. 8 | 9 | Two or more consecutive newlines `\n\n` or `\r\n\r\n` are considered as a 10 | paragraph and text between them is wrapped in `

` tags. 11 | One newline `\n` or `\r\n` is considered as a linebreak and a `
` tag is inserted. 12 | 13 | ## Examples 14 | 15 | iex> text_to_html("Hello\n\nWorld") |> safe_to_string 16 | "

Hello

\n

World

\n" 17 | 18 | iex> text_to_html("Hello\nWorld") |> safe_to_string 19 | "

Hello
\nWorld

\n" 20 | 21 | iex> opts = [wrapper_tag: :div, attributes: [class: "p"]] 22 | ...> text_to_html("Hello\n\nWorld", opts) |> safe_to_string 23 | "
Hello
\n
World
\n" 24 | 25 | ## Options 26 | 27 | * `:escape` - if `false` does not html escape input (default: `true`) 28 | * `:wrapper_tag` - tag to wrap each paragraph (default: `:p`) 29 | * `:attributes` - html attributes of the wrapper tag (default: `[]`) 30 | * `:insert_brs` - if `true` insert `
` for single line breaks (default: `true`) 31 | 32 | """ 33 | @spec text_to_html(Phoenix.HTML.unsafe(), Keyword.t()) :: Phoenix.HTML.safe() 34 | def text_to_html(string, opts \\ []) do 35 | escape? = Keyword.get(opts, :escape, true) 36 | wrapper_tag = Keyword.get(opts, :wrapper_tag, :p) 37 | attributes = Keyword.get(opts, :attributes, []) 38 | insert_brs? = Keyword.get(opts, :insert_brs, true) 39 | 40 | string 41 | |> maybe_html_escape(escape?) 42 | |> String.split(["\n\n", "\r\n\r\n"], trim: true) 43 | |> Enum.filter(¬_blank?/1) 44 | |> Enum.map(&wrap_paragraph(&1, wrapper_tag, attributes, insert_brs?)) 45 | |> Phoenix.HTML.html_escape() 46 | end 47 | 48 | defp maybe_html_escape(string, true), 49 | do: string |> Phoenix.HTML.Engine.html_escape() |> IO.iodata_to_binary() 50 | 51 | defp maybe_html_escape(string, false), 52 | do: string 53 | 54 | defp not_blank?("\r\n" <> rest), do: not_blank?(rest) 55 | defp not_blank?("\n" <> rest), do: not_blank?(rest) 56 | defp not_blank?(" " <> rest), do: not_blank?(rest) 57 | defp not_blank?(""), do: false 58 | defp not_blank?(_), do: true 59 | 60 | defp wrap_paragraph(text, tag, attributes, insert_brs?) do 61 | [PhoenixHTMLHelpers.Tag.content_tag(tag, insert_brs(text, insert_brs?), attributes), ?\n] 62 | end 63 | 64 | defp insert_brs(text, false) do 65 | text 66 | |> split_lines() 67 | |> Enum.intersperse(?\s) 68 | |> Phoenix.HTML.raw() 69 | end 70 | 71 | defp insert_brs(text, true) do 72 | text 73 | |> split_lines() 74 | |> Enum.map(&Phoenix.HTML.raw/1) 75 | |> Enum.intersperse([PhoenixHTMLHelpers.Tag.tag(:br), ?\n]) 76 | end 77 | 78 | defp split_lines(text) do 79 | String.split(text, ["\n", "\r\n"], trim: true) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, 3 | "ex_doc": {:hex, :ex_doc, "0.30.6", "5f8b54854b240a2b55c9734c4b1d0dd7bdd41f71a095d42a70445c03cf05a281", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bd48f2ddacf4e482c727f9293d9498e0881597eae6ddc3d9562bd7923375109f"}, 4 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 7 | "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, 8 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 9 | "phoenix_html": {:hex, :phoenix_html, "4.0.0", "4857ec2edaccd0934a923c2b0ba526c44a173c86b847e8db725172e9e51d11d6", [:mix], [], "hexpm", "cee794a052f243291d92fa3ccabcb4c29bb8d236f655fb03bcbdc3a8214b8d13"}, 10 | "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, 11 | "plug_crypto": {:hex, :plug_crypto, "1.2.0", "1cb20793aa63a6c619dd18bb33d7a3aa94818e5fd39ad357051a67f26dfa2df6", [:mix], [], "hexpm", "a48b538ae8bf381ffac344520755f3007cc10bd8e90b240af98ea29b69683fc2"}, 12 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 13 | } 14 | -------------------------------------------------------------------------------- /lib/phoenix_html_helpers/form_data.ex: -------------------------------------------------------------------------------- 1 | defimpl Phoenix.HTML.FormData, for: [Plug.Conn, Atom] do 2 | def to_form(conn_or_atom_or_map, opts) do 3 | {name, params, opts} = name_params_and_opts(conn_or_atom_or_map, opts) 4 | {errors, opts} = Keyword.pop(opts, :errors, []) 5 | id = Keyword.get(opts, :id) || name 6 | 7 | unless is_binary(id) or is_nil(id) do 8 | raise ArgumentError, ":id option in form_for must be a binary/string, got: #{inspect(id)}" 9 | end 10 | 11 | %Phoenix.HTML.Form{ 12 | source: conn_or_atom_or_map, 13 | impl: __MODULE__, 14 | id: id, 15 | name: name, 16 | params: params, 17 | data: %{}, 18 | errors: errors, 19 | options: opts 20 | } 21 | end 22 | 23 | case @for do 24 | Atom -> 25 | defp name_params_and_opts(atom, opts) do 26 | {params, opts} = Keyword.pop(opts, :params, %{}) 27 | {Atom.to_string(atom), params, opts} 28 | end 29 | 30 | Plug.Conn -> 31 | defp name_params_and_opts(conn, opts) do 32 | case Keyword.pop(opts, :as) do 33 | {nil, opts} -> 34 | {nil, conn.params, opts} 35 | 36 | {name, opts} -> 37 | name = to_string(name) 38 | {name, Map.get(conn.params, name) || %{}, opts} 39 | end 40 | end 41 | end 42 | 43 | def to_form(conn_or_atom_or_map, form, field, opts) when is_atom(field) or is_binary(field) do 44 | {default, opts} = Keyword.pop(opts, :default, %{}) 45 | {prepend, opts} = Keyword.pop(opts, :prepend, []) 46 | {append, opts} = Keyword.pop(opts, :append, []) 47 | {name, opts} = Keyword.pop(opts, :as) 48 | {id, opts} = Keyword.pop(opts, :id) 49 | {hidden, opts} = Keyword.pop(opts, :hidden, []) 50 | 51 | id = to_string(id || form.id <> "_#{field}") 52 | name = to_string(name || form.name <> "[#{field}]") 53 | params = Map.get(form.params, field_to_string(field)) 54 | 55 | cond do 56 | # cardinality: one 57 | is_map(default) -> 58 | [ 59 | %Phoenix.HTML.Form{ 60 | source: conn_or_atom_or_map, 61 | impl: __MODULE__, 62 | id: id, 63 | name: name, 64 | data: default, 65 | params: params || %{}, 66 | hidden: hidden, 67 | options: opts 68 | } 69 | ] 70 | 71 | # cardinality: many 72 | is_list(default) -> 73 | entries = 74 | if params do 75 | params 76 | |> Enum.sort_by(&elem(&1, 0)) 77 | |> Enum.map(&{nil, elem(&1, 1)}) 78 | else 79 | Enum.map(prepend ++ default ++ append, &{&1, %{}}) 80 | end 81 | 82 | for {{data, params}, index} <- Enum.with_index(entries) do 83 | index_string = Integer.to_string(index) 84 | 85 | %Phoenix.HTML.Form{ 86 | source: conn_or_atom_or_map, 87 | impl: __MODULE__, 88 | index: index, 89 | id: id <> "_" <> index_string, 90 | name: name <> "[" <> index_string <> "]", 91 | data: data, 92 | params: params, 93 | hidden: hidden, 94 | options: opts 95 | } 96 | end 97 | end 98 | end 99 | 100 | def input_value(_conn_or_atom_or_map, %{data: data, params: params}, field) 101 | when is_atom(field) or is_binary(field) do 102 | key = field_to_string(field) 103 | 104 | case params do 105 | %{^key => value} -> value 106 | %{} -> Map.get(data, field) 107 | end 108 | end 109 | 110 | def input_validations(_conn_or_atom_or_map, _form, _field), do: [] 111 | 112 | # Normalize field name to string version 113 | defp field_to_string(field) when is_atom(field), do: Atom.to_string(field) 114 | defp field_to_string(field) when is_binary(field), do: field 115 | end 116 | -------------------------------------------------------------------------------- /test/phoenix_html_helpers/link_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers.LinkTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Phoenix.HTML 5 | import PhoenixHTMLHelpers.Link 6 | 7 | test "link with post" do 8 | csrf_token = Plug.CSRFProtection.get_csrf_token() 9 | 10 | assert safe_to_string(link("hello", to: "/world", method: :post)) == 11 | ~s[hello] 12 | end 13 | 14 | test "link with %URI{}" do 15 | url = "https://elixir-lang.org/" 16 | 17 | assert safe_to_string(link("elixir", to: url)) == 18 | safe_to_string(link("elixir", to: URI.parse(url))) 19 | 20 | path = "/elixir" 21 | 22 | assert safe_to_string(link("elixir", to: path)) == 23 | safe_to_string(link("elixir", to: URI.parse(path))) 24 | end 25 | 26 | test "link with put/delete" do 27 | csrf_token = Plug.CSRFProtection.get_csrf_token() 28 | 29 | assert safe_to_string(link("hello", to: "/world", method: :put)) == 30 | ~s[hello] 31 | end 32 | 33 | test "link with put/delete without csrf_token" do 34 | assert safe_to_string(link("hello", to: "/world", method: :put, csrf_token: false)) == 35 | ~s[hello] 36 | end 37 | 38 | test "link with :do contents" do 39 | assert ~s[

world

] == 40 | safe_to_string( 41 | link to: "/hello" do 42 | PhoenixHTMLHelpers.Tag.content_tag(:p, "world") 43 | end 44 | ) 45 | 46 | assert safe_to_string( 47 | link(to: "/hello") do 48 | "world" 49 | end 50 | ) == ~s[world] 51 | end 52 | 53 | test "link with scheme" do 54 | assert safe_to_string(link("foo", to: "/javascript:alert(<1>)")) == 55 | ~s[foo] 56 | 57 | assert safe_to_string(link("foo", to: {:safe, "/javascript:alert(<1>)"})) == 58 | ~s[foo] 59 | 60 | assert safe_to_string(link("foo", to: {:javascript, "alert(<1>)"})) == 61 | ~s[foo] 62 | 63 | assert safe_to_string(link("foo", to: {:javascript, ~c"alert(<1>)"})) == 64 | ~s[foo] 65 | 66 | assert safe_to_string(link("foo", to: {:javascript, {:safe, "alert(<1>)"}})) == 67 | ~s[foo] 68 | 69 | assert safe_to_string(link("foo", to: {:javascript, {:safe, ~c"alert(<1>)"}})) == 70 | ~s[foo] 71 | end 72 | 73 | test "link with invalid args" do 74 | msg = "expected non-nil value for :to in link/2" 75 | 76 | assert_raise ArgumentError, msg, fn -> 77 | link("foo", bar: "baz") 78 | end 79 | 80 | msg = "link/2 requires a keyword list as second argument" 81 | 82 | assert_raise ArgumentError, msg, fn -> 83 | link("foo", "/login") 84 | end 85 | 86 | assert_raise ArgumentError, ~r"unsupported scheme given as link", fn -> 87 | link("foo", to: "javascript:alert(1)") 88 | end 89 | 90 | assert_raise ArgumentError, ~r"unsupported scheme given as link", fn -> 91 | link("foo", to: {:safe, "javascript:alert(1)"}) 92 | end 93 | 94 | assert_raise ArgumentError, ~r"unsupported scheme given as link", fn -> 95 | link("foo", to: {:safe, ~c"javascript:alert(1)"}) 96 | end 97 | end 98 | 99 | test "button with post (default)" do 100 | csrf_token = Plug.CSRFProtection.get_csrf_token() 101 | 102 | assert safe_to_string(button("hello", to: "/world")) == 103 | ~s[] 104 | end 105 | 106 | test "button with %URI{}" do 107 | url = "https://elixir-lang.org/" 108 | 109 | assert safe_to_string(button("elixir", to: url, csrf_token: false)) == 110 | safe_to_string(button("elixir", to: URI.parse(url), csrf_token: false)) 111 | end 112 | 113 | test "button with post without csrf_token" do 114 | assert safe_to_string(button("hello", to: "/world", csrf_token: false)) == 115 | ~s[] 116 | end 117 | 118 | test "button with get does not generate CSRF" do 119 | assert safe_to_string(button("hello", to: "/world", method: :get)) == 120 | ~s[] 121 | end 122 | 123 | test "button with do" do 124 | csrf_token = Plug.CSRFProtection.get_csrf_token() 125 | 126 | output = 127 | safe_to_string( 128 | button to: "/world", class: "small" do 129 | raw("Hi") 130 | end 131 | ) 132 | 133 | assert output == 134 | ~s[] 135 | end 136 | 137 | test "button with class overrides default" do 138 | csrf_token = Plug.CSRFProtection.get_csrf_token() 139 | 140 | assert safe_to_string(button("hello", to: "/world", class: "btn rounded", id: "btn")) == 141 | ~s[] 142 | end 143 | 144 | test "button with invalid args" do 145 | assert_raise ArgumentError, ~r/unsupported scheme given as link/, fn -> 146 | button("foo", to: "javascript:alert(1)", method: :get) 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /test/phoenix_html_helpers/inputs_for_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers.InputsForTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Phoenix.HTML 5 | import PhoenixHTMLHelpers.Form 6 | 7 | @doc """ 8 | A function that executes `inputs_for/4` and 9 | extracts its inner contents for assertion. 10 | """ 11 | def safe_inputs_for(field, opts \\ [], fun) do 12 | mark = "--PLACEHOLDER--" 13 | {multipart, opts} = Keyword.pop(opts, :multipart, false) 14 | 15 | conn = 16 | Plug.Test.conn(:get, "/foo", %{ 17 | "search" => %{ 18 | "date" => %{"year" => "2020", "month" => "4", "day" => "17"}, 19 | "dates" => %{ 20 | "0" => %{"year" => "2010", "month" => "4", "day" => "17"}, 21 | "1" => %{"year" => "2020", "month" => "4", "day" => "17"} 22 | } 23 | } 24 | }) 25 | 26 | contents = 27 | safe_to_string( 28 | form_for(conn, "/", [as: :search, multipart: multipart], fn f -> 29 | html_escape([mark, inputs_for(f, field, opts, fun), mark]) 30 | end) 31 | ) 32 | 33 | [_, inner, _] = String.split(contents, mark) 34 | inner 35 | end 36 | 37 | ## Cardinality one 38 | 39 | test "one: inputs_for/4 without default and field is not present" do 40 | contents = 41 | safe_inputs_for(:unknown, fn f -> 42 | refute f.index 43 | text_input(f, :year) 44 | end) 45 | 46 | assert contents == 47 | ~s() 48 | end 49 | 50 | test "one: inputs_for/4 does not generate index" do 51 | safe_inputs_for(:unknown, fn f -> 52 | refute f.index 53 | "ok" 54 | end) 55 | end 56 | 57 | test "one: inputs_for/4 without default and field is present" do 58 | contents = 59 | safe_inputs_for(:date, fn f -> 60 | text_input(f, :year) 61 | end) 62 | 63 | assert contents == 64 | ~s() 65 | end 66 | 67 | test "one: inputs_for/4 with default and field is not present" do 68 | contents = 69 | safe_inputs_for(:unknown, [default: %{year: 2015}], fn f -> 70 | text_input(f, :year) 71 | end) 72 | 73 | assert contents == 74 | ~s() 75 | end 76 | 77 | test "one: inputs_for/4 with default and field is present" do 78 | contents = 79 | safe_inputs_for(:date, [default: %{year: 2015}], fn f -> 80 | text_input(f, :year) 81 | end) 82 | 83 | assert contents == 84 | ~s() 85 | end 86 | 87 | test "one: inputs_for/4 with custom name and id" do 88 | contents = 89 | safe_inputs_for(:date, [as: :foo, id: :bar], fn f -> 90 | text_input(f, :year) 91 | end) 92 | 93 | assert contents == ~s() 94 | end 95 | 96 | ## Cardinality many 97 | 98 | test "many: inputs_for/4 with file field generates file input" do 99 | contents = 100 | safe_inputs_for(:unknown, [default: [%{}, %{}], multipart: true], fn f -> 101 | assert f.index in [0, 1] 102 | file_input(f, :file) 103 | end) 104 | 105 | assert contents == 106 | ~s() <> 107 | ~s() 108 | end 109 | 110 | test "many: inputs_for/4 with default and field is not present" do 111 | contents = 112 | safe_inputs_for(:unknown, [default: [%{year: 2012}, %{year: 2018}]], fn f -> 113 | assert f.index in [0, 1] 114 | text_input(f, :year) 115 | end) 116 | 117 | assert contents == 118 | ~s() <> 119 | ~s() 120 | end 121 | 122 | test "many: inputs_for/4 generates indexes" do 123 | safe_inputs_for(:unknown, [default: [%{year: 2012}]], fn f -> 124 | assert f.index == 0 125 | "ok" 126 | end) 127 | 128 | safe_inputs_for(:unknown, [default: [%{year: 2012}, %{year: 2018}]], fn f -> 129 | assert f.index in [0, 1] 130 | "ok" 131 | end) 132 | end 133 | 134 | test "many: inputs_for/4 with default and field is present" do 135 | contents = 136 | safe_inputs_for(:dates, [default: [%{year: 2012}, %{year: 2018}]], fn f -> 137 | text_input(f, :year) 138 | end) 139 | 140 | assert contents == 141 | ~s() <> 142 | ~s() 143 | end 144 | 145 | test "many: inputs_for/4 with name and id" do 146 | contents = 147 | safe_inputs_for( 148 | :dates, 149 | [default: [%{year: 2012}, %{year: 2018}], as: :foo, id: :bar], 150 | fn f -> 151 | text_input(f, :year) 152 | end 153 | ) 154 | 155 | assert contents == 156 | ~s() <> 157 | ~s() 158 | end 159 | 160 | @prepend_append [ 161 | prepend: [%{year: 2008}], 162 | append: [%{year: 2022}], 163 | default: [%{year: 2012}, %{year: 2018}] 164 | ] 165 | 166 | test "many: inputs_for/4 with prepend/append and field is not present" do 167 | contents = 168 | safe_inputs_for(:unknown, @prepend_append, fn f -> 169 | text_input(f, :year) 170 | end) 171 | 172 | assert contents == 173 | ~s() <> 174 | ~s() <> 175 | ~s() <> 176 | ~s() 177 | end 178 | 179 | test "many: inputs_for/4 with prepend/append and field is present" do 180 | contents = 181 | safe_inputs_for(:dates, @prepend_append, fn f -> 182 | text_input(f, :year) 183 | end) 184 | 185 | assert contents == 186 | ~s() <> 187 | ~s() 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /lib/phoenix_html_helpers/link.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers.Link do 2 | @moduledoc """ 3 | Conveniences for working with links and URLs in HTML. 4 | """ 5 | 6 | import PhoenixHTMLHelpers.Tag 7 | 8 | @doc """ 9 | Generates a link to the given URL. 10 | 11 | ## Examples 12 | 13 | link("hello", to: "/world") 14 | #=> hello 15 | 16 | link("hello", to: URI.parse("https://elixir-lang.org")) 17 | #=> hello 18 | 19 | link("", to: "/world") 20 | #=> <hello> 21 | 22 | link("", to: "/world", class: "btn") 23 | #=> <hello> 24 | 25 | link("delete", to: "/the_world", data: [confirm: "Really?"]) 26 | #=> delete 27 | 28 | # If you supply a method other than `:get`: 29 | link("delete", to: "/everything", method: :delete) 30 | #=> delete 31 | 32 | # You can use a `do ... end` block too: 33 | link to: "/hello" do 34 | "world" 35 | end 36 | #=> world 37 | 38 | ## Options 39 | 40 | * `:to` - the page to link to. This option is required 41 | 42 | * `:method` - the method to use with the link. In case the 43 | method is not `:get`, the link is generated inside the form 44 | which sets the proper information. In order to submit the 45 | form, JavaScript must be enabled 46 | 47 | * `:csrf_token` - a custom token to use for links with a method 48 | other than `:get`. 49 | 50 | All other options are forwarded to the underlying `` tag. 51 | 52 | ## Data attributes 53 | 54 | Data attributes are added as a keyword list passed to the `data` key. 55 | The following data attributes are supported: 56 | 57 | * `data-confirm` - shows a confirmation prompt before 58 | generating and submitting the form when `:method` 59 | is not `:get`. 60 | 61 | ## CSRF Protection 62 | 63 | By default, CSRF tokens are generated through `Plug.CSRFProtection`. 64 | """ 65 | def link(text, opts) 66 | 67 | def link(opts, do: contents) when is_list(opts) do 68 | link(contents, opts) 69 | end 70 | 71 | def link(_text, opts) when not is_list(opts) do 72 | raise ArgumentError, "link/2 requires a keyword list as second argument" 73 | end 74 | 75 | def link(text, opts) do 76 | {to, opts} = pop_required_option!(opts, :to, "expected non-nil value for :to in link/2") 77 | {method, opts} = Keyword.pop(opts, :method, :get) 78 | 79 | if method == :get do 80 | # Call link attributes to validate `to` 81 | [data: data] = link_attributes(to, []) 82 | content_tag(:a, text, [href: data[:to]] ++ Keyword.delete(opts, :csrf_token)) 83 | else 84 | {csrf_token, opts} = Keyword.pop(opts, :csrf_token, true) 85 | opts = Keyword.put_new(opts, :rel, "nofollow") 86 | [data: data] = link_attributes(to, method: method, csrf_token: csrf_token) 87 | content_tag(:a, text, [data: data, href: data[:to]] ++ opts) 88 | end 89 | end 90 | 91 | @doc """ 92 | Generates a button tag that uses the Javascript function handleClick() 93 | (see phoenix_html.js) to submit the form data. 94 | 95 | Useful to ensure that links that change data are not triggered by 96 | search engines and other spidering software. 97 | 98 | ## Examples 99 | 100 | button("hello", to: "/world") 101 | #=> 102 | 103 | button("hello", to: "/world", method: :get, class: "btn") 104 | #=> 105 | 106 | ## Options 107 | 108 | * `:to` - the page to link to. This option is required 109 | 110 | * `:method` - the method to use with the button. Defaults to :post. 111 | 112 | All other options are forwarded to the underlying button input. 113 | 114 | When the `:method` is set to `:get` and the `:to` URL contains query 115 | parameters the generated form element will strip the parameters in accordance 116 | with the [W3C](https://www.w3.org/TR/html401/interact/forms.html#h-17.13.3.4) 117 | form specification. 118 | 119 | ## Data attributes 120 | 121 | Data attributes are added as a keyword list passed to the 122 | `data` key. The following data attributes are supported: 123 | 124 | * `data-confirm` - shows a confirmation prompt before generating and 125 | submitting the form. 126 | """ 127 | def button(opts, do: contents) do 128 | button(contents, opts) 129 | end 130 | 131 | def button(text, opts) do 132 | {to, opts} = pop_required_option!(opts, :to, "option :to is required in button/2") 133 | 134 | {link_opts, opts} = 135 | opts 136 | |> Keyword.put_new(:method, :post) 137 | |> Keyword.split([:method, :csrf_token]) 138 | 139 | content_tag(:button, text, link_attributes(to, link_opts) ++ opts) 140 | end 141 | 142 | defp pop_required_option!(opts, key, error_message) do 143 | {value, opts} = Keyword.pop(opts, key) 144 | 145 | unless value do 146 | raise ArgumentError, error_message 147 | end 148 | 149 | {value, opts} 150 | end 151 | 152 | defp link_attributes(to, opts) do 153 | to = valid_destination!(to) 154 | method = Keyword.get(opts, :method, :get) 155 | data = [method: method, to: to] 156 | 157 | data = 158 | if method == :get do 159 | data 160 | else 161 | case Keyword.get(opts, :csrf_token, true) do 162 | true -> [csrf: PhoenixHTMLHelpers.Tag.csrf_token_value(to)] ++ data 163 | false -> data 164 | csrf when is_binary(csrf) -> [csrf: csrf] ++ data 165 | end 166 | end 167 | 168 | [data: data] 169 | end 170 | 171 | defp valid_destination!(%URI{} = uri) do 172 | valid_destination!(URI.to_string(uri)) 173 | end 174 | 175 | defp valid_destination!({:safe, to}) do 176 | {:safe, valid_string_destination!(IO.iodata_to_binary(to))} 177 | end 178 | 179 | defp valid_destination!({other, to}) when is_atom(other) do 180 | [Atom.to_string(other), ?:, to] 181 | end 182 | 183 | defp valid_destination!(to) do 184 | valid_string_destination!(IO.iodata_to_binary(to)) 185 | end 186 | 187 | @valid_uri_schemes ~w(http: https: ftp: ftps: mailto: news: irc: gopher:) ++ 188 | ~w(nntp: feed: telnet: mms: rtsp: svn: tel: fax: xmpp:) 189 | 190 | for scheme <- @valid_uri_schemes do 191 | defp valid_string_destination!(unquote(scheme) <> _ = string), do: string 192 | end 193 | 194 | defp valid_string_destination!(to) do 195 | if not match?("/" <> _, to) and String.contains?(to, ":") do 196 | raise ArgumentError, """ 197 | unsupported scheme given as link. In case you want to link to an 198 | unknown or unsafe scheme, such as javascript, use a tuple: {:javascript, rest}\ 199 | """ 200 | else 201 | to 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /test/phoenix_html_helpers/tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixHTMLHelpers.TagTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Phoenix.HTML 5 | import PhoenixHTMLHelpers.Tag 6 | doctest PhoenixHTMLHelpers.Tag 7 | 8 | test "tag" do 9 | assert tag(:br) |> safe_to_string() == ~s(
) 10 | 11 | assert tag(:input, name: ~s("<3")) |> safe_to_string() == ~s() 12 | assert tag(:input, name: raw("<3")) |> safe_to_string() == ~s() 13 | assert tag(:input, name: ["foo", raw("b safe_to_string() == ~s() 14 | assert tag(:input, name: :hello) |> safe_to_string() == ~s() 15 | 16 | assert tag(:input, type: "text", name: "user_id") |> safe_to_string() == 17 | ~s() 18 | 19 | assert tag(:input, data: [toggle: "dropdown"]) |> safe_to_string() == 20 | ~s() 21 | 22 | assert tag(:input, my_attr: "blah") |> safe_to_string() == ~s() 23 | 24 | assert tag(:input, [{"my_<_attr", "blah"}]) |> safe_to_string() == 25 | ~s() 26 | 27 | assert tag(:input, [{{:safe, "my_<_attr"}, "blah"}]) |> safe_to_string() == 28 | ~s() 29 | 30 | assert tag(:input, data: [my_attr: "blah"]) |> safe_to_string() == 31 | ~s() 32 | 33 | assert tag(:input, data: [toggle: [attr: "blah", target: "#parent"]]) |> safe_to_string() == 34 | ~s() 35 | 36 | assert tag(:audio, autoplay: "autoplay") |> safe_to_string() == 37 | ~s(