├── .formatter.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── .tool-versions ├── LICENSE ├── README.md ├── config └── config.exs ├── lib ├── xml_builder.ex └── xml_builder │ └── format │ ├── indented.ex │ └── none.ex ├── mix.exs ├── mix.lock └── test ├── test_helper.exs └── xml_builder_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: mix 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Test (${{matrix.elixir}}/${{matrix.otp}}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | otp: [24.x, 25.x, 26.x] 12 | elixir: [1.12.x, 1.13.x, 1.14.x, 1.15.x, 1.16.x] 13 | exclude: 14 | - otp: 26.x 15 | elixir: 1.13.x 16 | - otp: 26.x 17 | elixir: 1.12.x 18 | - otp: 25.x 19 | elixir: 1.12.x 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: erlef/setup-beam@v1 23 | with: 24 | elixir-version: ${{ matrix.elixir }} 25 | otp-version: ${{ matrix.otp }} 26 | - name: Restore dependencies cache 27 | uses: actions/cache@v4 28 | with: 29 | path: deps 30 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 31 | restore-keys: ${{ runner.os }}-mix- 32 | - run: mix deps.get 33 | - run: mix compile --warnings-as-errors 34 | - run: mix format --check-formatted 35 | - run: mix credo --strict 36 | - run: mix test 37 | -------------------------------------------------------------------------------- /.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 | xml_builder-*.tar 24 | 25 | 26 | # Temporary files for e.g. tests 27 | /tmp 28 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.18.3 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014-present, Joshua Nussbaum. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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 THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | XML Builder 2 | =========== 3 | 4 | [![CI](https://github.com/joshnuss/xml_builder/workflows/mix/badge.svg)](https://github.com/joshnuss/xml_builder/actions) 5 | [![Module Version](https://img.shields.io/hexpm/v/xml_builder.svg)](https://hex.pm/packages/xml_builder) 6 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/xml_builder/) 7 | [![Total Download](https://img.shields.io/hexpm/dt/xml_builder.svg)](https://hex.pm/packages/xml_builder) 8 | [![License](https://img.shields.io/hexpm/l/xml_builder.svg)](https://github.com/joshnuss/xml_builder/blob/master/LICENSE) 9 | [![Last Updated](https://img.shields.io/github/last-commit/joshnuss/xml_builder.svg)](https://github.com/joshnuss/xml_builder/commits/master) 10 | 11 | 12 | ## Overview 13 | 14 | An Elixir library for building XML. It is inspired by the late [Jim Weirich](https://github.com/jimweirich)'s awesome [builder](https://github.com/jimweirich/builder) library for Ruby. 15 | 16 | Each XML node is structured as a tuple of name, attributes map, and content/list. 17 | 18 | ```elixir 19 | {name, attrs, content | list} 20 | ``` 21 | 22 | ## Installation 23 | 24 | Add dependency to your project's `mix.exs`: 25 | 26 | ```elixir 27 | def deps do 28 | [{:xml_builder, "~> 2.1"}] 29 | end 30 | ``` 31 | 32 | ## Examples 33 | 34 | ### A simple element 35 | 36 | Like `Josh`, would look like: 37 | 38 | ```elixir 39 | {:person, %{id: 12345}, "Josh"} |> XmlBuilder.generate 40 | ``` 41 | 42 | ### An element with child elements 43 | 44 | Like `JoshNussbaum`. 45 | 46 | ```elixir 47 | {:person, %{id: 12345}, [{:first, nil, "Josh"}, {:last, nil, "Nussbaum"}]} |> XmlBuilder.generate 48 | ``` 49 | 50 | ### Convenience Functions 51 | 52 | For more readability, you can use XmlBuilder's methods instead of creating tuples manually. 53 | 54 | ```elixir 55 | XmlBuilder.document(:person, "Josh") |> XmlBuilder.generate 56 | ``` 57 | 58 | Outputs: 59 | 60 | ```xml 61 | 62 | Josh 63 | ``` 64 | 65 | #### Building up an element 66 | 67 | An element can be built using multiple calls to the `element` function. 68 | 69 | ```elixir 70 | import XmlBuilder 71 | 72 | def person(id, first, last) do 73 | element(:person, %{id: id}, [ 74 | element(:first, first), 75 | element(:last, last) 76 | ]) 77 | end 78 | 79 | iex> [person(123, "Steve", "Jobs"), 80 | person(456, "Steve", "Wozniak")] |> generate 81 | ``` 82 | 83 | Outputs. 84 | 85 | ```xml 86 | 87 | Steve 88 | Jobs 89 | 90 | 91 | Steve 92 | Wozniak 93 | 94 | ``` 95 | 96 | #### Using keyed lists 97 | 98 | The previous example can be simplified using a keyed list. 99 | 100 | ```elixir 101 | import XmlBuilder 102 | 103 | def person(id, first, last) do 104 | element(:person, %{id: id}, first: first, 105 | last: last) 106 | end 107 | 108 | iex> person(123, "Josh", "Nussbaum") |> generate(format: :none) 109 | "JoshNussbaum" 110 | ``` 111 | 112 | #### Namespaces 113 | 114 | To use a namespace, add an `xmlns` attribute to the root element. 115 | 116 | To use multiple schemas, specify a `xmlns:nsName` attribute for each schema and use a colon `:` in the element name, ie `nsName:elementName`. 117 | 118 | ```elixir 119 | import XmlBuilder 120 | 121 | iex> generate({:example, [xmlns: "http://schemas.example.tld/1999"], "content"}) 122 | "content" 123 | 124 | iex> generate({:"nsName:elementName", ["xmlns:nsName": "http://schemas.example.tld/1999"], "content"}) 125 | "content" 126 | ``` 127 | 128 | ### DOCTYPE declarations 129 | 130 | A DOCTYPE can be declared by applying the `doctype` function at the first position of a list of elements in a `document` definition: 131 | 132 | ```elixir 133 | import XmlBuilder 134 | 135 | document([ 136 | doctype("html", public: ["-//W3C//DTD XHTML 1.0 Transitional//EN", 137 | "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"]), 138 | element(:html, "Hello, world!") 139 | ]) |> generate 140 | ``` 141 | 142 | Outputs. 143 | 144 | ```xml 145 | 146 | 147 | Hello, world! 148 | ``` 149 | 150 | ### Encoding 151 | 152 | While the output is always UTF-8 and has to be converted in another place, you can override the encoding statement in the XML declaration with the `encoding` option. 153 | 154 | ```elixir 155 | import XmlBuilder 156 | 157 | document(:oldschool) 158 | |> generate(encoding: "ISO-8859-1") 159 | |> :unicode.characters_to_binary(:unicode, :latin1) 160 | ``` 161 | 162 | Outputs. 163 | 164 | ```xml 165 | 166 | 167 | ``` 168 | 169 | ### Using `iodata()` directly 170 | 171 | While by default, output from `generate/2` is converted to `binary()`, you can use `generate_iodata/2` to skip this conversion. This can be convenient if you're using `IO.binwrite/2` on a `:raw` IO device, as these APIs can work with `iodata()` directly, leading to some performance gains. 172 | 173 | In some scenarios, it may be beneficial to generate part of your XML upfront, for instance when generating a `sitemap.xml`, you may have shared fields for `author`. Instead of generating this each time, you could do the following: 174 | 175 | ```elixir 176 | import XmlBuilder 177 | 178 | entries = [%{title: "Test", url: "https://example.org/"}] 179 | 180 | # Generate static author data upfront 181 | author = generate_iodata(element(:author, [ 182 | element(:name, "John Doe"), 183 | element(:uri, "https://example.org/") 184 | ])) 185 | 186 | file = File.open!("path/to/file", [:raw]) 187 | 188 | for entry <- entries do 189 | iodata = 190 | generate_iodata(element(:entry, [ 191 | # Reuse the static pre-generated fields as-is 192 | {:iodata, author}, 193 | 194 | # Dynamic elements are generated for each entry 195 | element(:title, entry.title), 196 | element(:link, entry.url) 197 | ])) 198 | 199 | IO.binwrite(file, iodata) 200 | end 201 | ``` 202 | 203 | ### Escaping 204 | 205 | XmlBuilder offers 3 distinct ways to control how content of tags is escaped and handled: 206 | 207 | - By default, any content is escaped, replacing reserved characters (`& " ' < >`) with their equivalent entity (`&` etc.) 208 | - If content is wrapped in `{:cdata, cdata}`, the content in `cdata` is wrapped with ``, and not escaped. You should make sure the content itself does not contain `]]>`. 209 | - If content is wrapped in `{:safe, data}`, the content in `data` is not escaped, but will be stringified if not a bitstring. Use this option carefully. It may be useful when data is guaranteed to be safe (numeric data). 210 | - If content is wrapped in `{:iodata, data}`, either in the top level or within a list, the `data` is used as `iodata()`, and will not be escaped, indented or stringified. An example of this can be seen in the "Using `iodata()` directly" example above. 211 | 212 | ### Standalone 213 | 214 | Should you need `standalone="yes"` in the XML declaration, you can pass `standalone: true` as option to the `generate/2` call. 215 | 216 | ```elixir 217 | import XmlBuilder 218 | 219 | document(:outsider) 220 | |> generate(standalone: true) 221 | ``` 222 | 223 | Outputs. 224 | 225 | ```xml 226 | 227 | 228 | ``` 229 | 230 | If otherwise you need `standalone ="no"` in the XML declaration, you can pass `standalone: false` as an option to the` generate / 2` call. 231 | 232 | Outputs. 233 | 234 | ```xml 235 | 236 | 237 | ``` 238 | 239 | ### Formatting 240 | 241 | To remove indentation, pass `format: :none` option to `XmlBuilder.generate/2`. 242 | 243 | ```elixir 244 | doc |> XmlBuilder.generate(format: :none) 245 | ``` 246 | 247 | The default is to formatting with indentation, which is equivalent to `XmlBuilder.generate(doc, format: :indent)`. 248 | 249 | ## License 250 | 251 | This source code is licensed under the [MIT License](https://github.com/joshnuss/xml_builder/blob/master/LICENSE). Copyright (c) 2014-present, Joshua Nussbaum. All rights reserved. 252 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies. The Mix.Config module provides functions 3 | # to aid in doing so. 4 | import Config 5 | 6 | # Note this file is loaded before any dependency and is restricted 7 | # to this project. If another project depends on this project, this 8 | # file won't be loaded nor affect the parent project. 9 | 10 | # Sample configuration: 11 | # 12 | # config :my_dep, 13 | # key: :value, 14 | # limit: 42 15 | 16 | # It is also possible to import configuration files, relative to this 17 | # directory. For example, you can emulate configuration per environment 18 | # by uncommenting the line below and defining dev.exs, test.exs and such. 19 | # Configuration from the imported file will override the ones defined 20 | # here (which is why it is important to import them last). 21 | # 22 | # import_config "#{Mix.env}.exs" 23 | -------------------------------------------------------------------------------- /lib/xml_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule XmlBuilder do 2 | @moduledoc """ 3 | A module for generating XML 4 | 5 | ## Examples 6 | 7 | iex> XmlBuilder.document(:person) |> XmlBuilder.generate 8 | "\\n" 9 | 10 | iex> XmlBuilder.document(:person, "Josh") |> XmlBuilder.generate 11 | "\\nJosh" 12 | 13 | iex> XmlBuilder.document(:person) |> XmlBuilder.generate(format: :none) 14 | "" 15 | 16 | iex> XmlBuilder.element(:person, "Josh") |> XmlBuilder.generate 17 | "Josh" 18 | 19 | iex> XmlBuilder.element(:person, %{occupation: "Developer"}, "Josh") |> XmlBuilder.generate 20 | "Josh" 21 | """ 22 | 23 | defmacrop is_blank_attrs(attrs) do 24 | quote do: is_blank_map(unquote(attrs)) or is_blank_list(unquote(attrs)) 25 | end 26 | 27 | defmacrop is_blank_list(list) do 28 | quote do: is_nil(unquote(list)) or unquote(list) == [] 29 | end 30 | 31 | defmacrop is_blank_map(map) do 32 | quote do: is_nil(unquote(map)) or unquote(map) == %{} 33 | end 34 | 35 | @doc """ 36 | Generate an XML document. 37 | 38 | Returns a `binary`. 39 | 40 | ## Examples 41 | 42 | iex> XmlBuilder.document(:person) |> XmlBuilder.generate 43 | "\\n" 44 | 45 | iex> XmlBuilder.document(:person, %{id: 1}) |> XmlBuilder.generate 46 | "\\n" 47 | 48 | iex> XmlBuilder.document(:person, %{id: 1}, "some data") |> XmlBuilder.generate 49 | "\\nsome data" 50 | """ 51 | def document(elements), 52 | do: [:xml_decl | elements_with_prolog(elements) |> List.wrap()] 53 | 54 | def document(name, attrs_or_content), 55 | do: [:xml_decl | [element(name, attrs_or_content)]] 56 | 57 | def document(name, attrs, content), 58 | do: [:xml_decl | [element(name, attrs, content)]] 59 | 60 | @doc false 61 | def doc(elements) do 62 | IO.warn("doc/1 is deprecated. Use document/1 with generate/1 instead.") 63 | [:xml_decl | elements_with_prolog(elements) |> List.wrap()] |> generate 64 | end 65 | 66 | @doc false 67 | def doc(name, attrs_or_content) do 68 | IO.warn("doc/2 is deprecated. Use document/2 with generate/1 instead.") 69 | [:xml_decl | [element(name, attrs_or_content)]] |> generate 70 | end 71 | 72 | @doc false 73 | def doc(name, attrs, content) do 74 | IO.warn("doc/3 is deprecated. Use document/3 with generate/1 instead.") 75 | [:xml_decl | [element(name, attrs, content)]] |> generate 76 | end 77 | 78 | @doc """ 79 | Create an XML element. 80 | 81 | Returns a `tuple` in the format `{name, attributes, content | list}`. 82 | 83 | ## Examples 84 | 85 | iex> XmlBuilder.element(:person) 86 | {:person, nil, nil} 87 | 88 | iex> XmlBuilder.element(:person, "data") 89 | {:person, nil, "data"} 90 | 91 | iex> XmlBuilder.element(:person, %{id: 1}) 92 | {:person, %{id: 1}, nil} 93 | 94 | iex> XmlBuilder.element(:person, %{id: 1}, "data") 95 | {:person, %{id: 1}, "data"} 96 | 97 | iex> XmlBuilder.element(:person, %{id: 1}, [XmlBuilder.element(:first, "Steve"), XmlBuilder.element(:last, "Jobs")]) 98 | {:person, %{id: 1}, [ 99 | {:first, nil, "Steve"}, 100 | {:last, nil, "Jobs"} 101 | ]} 102 | """ 103 | def element(name) when is_bitstring(name), 104 | do: element({nil, nil, name}) 105 | 106 | def element({:iodata, _data} = iodata), 107 | do: element({nil, nil, iodata}) 108 | 109 | def element(name) when is_bitstring(name) or is_atom(name), 110 | do: element({name}) 111 | 112 | def element(list) when is_list(list), 113 | do: list |> Enum.reject(&is_nil/1) |> Enum.map(&element/1) 114 | 115 | def element({name}), 116 | do: element({name, nil, nil}) 117 | 118 | def element({name, attrs}) when is_map(attrs), 119 | do: element({name, attrs, nil}) 120 | 121 | def element({name, content}), 122 | do: element({name, nil, content}) 123 | 124 | def element({name, attrs, content}) when is_list(content), 125 | do: {name, attrs, element(content)} 126 | 127 | def element({name, attrs, content}), 128 | do: {name, attrs, content} 129 | 130 | def element(name, attrs) when is_map(attrs), 131 | do: element({name, attrs, nil}) 132 | 133 | def element(name, content), 134 | do: element({name, nil, content}) 135 | 136 | def element(name, attrs, content), 137 | do: element({name, attrs, content}) 138 | 139 | @doc """ 140 | Creates a DOCTYPE declaration with a system or public identifier. 141 | 142 | ## System Example 143 | 144 | Returns a `tuple` in the format `{:doctype, {:system, name, system_identifier}}`. 145 | 146 | ```elixir 147 | import XmlBuilder 148 | 149 | document([ 150 | doctype("greeting", system: "hello.dtd"), 151 | element(:person, "Josh") 152 | ]) |> generate 153 | ``` 154 | 155 | Outputs 156 | 157 | ```xml 158 | 159 | 160 | Josh 161 | ``` 162 | 163 | ## Public Example 164 | 165 | Returns a `tuple` in the format `{:doctype, {:public, name, public_identifier, system_identifier}}`. 166 | 167 | ```elixir 168 | import XmlBuilder 169 | 170 | document([ 171 | doctype("html", public: ["-//W3C//DTD XHTML 1.0 Transitional//EN", 172 | "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"]), 173 | element(:html, "Hello, world!") 174 | ]) |> generate 175 | ``` 176 | 177 | Outputs 178 | 179 | ```xml 180 | 181 | 182 | Hello, world! 183 | ``` 184 | """ 185 | def doctype(name, [{:system, system_identifier}]), 186 | do: {:doctype, {:system, name, system_identifier}} 187 | 188 | def doctype(name, [{:public, [public_identifier, system_identifier]}]), 189 | do: {:doctype, {:public, name, public_identifier, system_identifier}} 190 | 191 | @doc """ 192 | Generate a binary from an XML tree 193 | 194 | Returns a `binary`. 195 | 196 | ## Examples 197 | 198 | iex> XmlBuilder.generate(XmlBuilder.element(:person)) 199 | "" 200 | 201 | iex> XmlBuilder.generate({:person, %{id: 1}, "Steve Jobs"}) 202 | "Steve Jobs" 203 | 204 | iex> XmlBuilder.generate({:name, nil, [{:first, nil, "Steve"}]}, format: :none) 205 | "Steve" 206 | 207 | iex> XmlBuilder.generate({:name, nil, [{:first, nil, "Steve"}]}, whitespace: "") 208 | "\\nSteve\\n" 209 | 210 | iex> XmlBuilder.generate({:name, nil, [{:first, nil, "Steve"}]}) 211 | "\\n Steve\\n" 212 | 213 | iex> XmlBuilder.generate(:xml_decl, encoding: "ISO-8859-1") 214 | ~s|| 215 | """ 216 | def generate(any, options \\ []), 217 | do: format(any, 0, options) |> IO.iodata_to_binary() 218 | 219 | @doc """ 220 | Similar to `generate/2`, but returns `iodata` instead of a `binary`. 221 | 222 | ## Examples 223 | 224 | iex> XmlBuilder.generate_iodata(XmlBuilder.element(:person)) 225 | ["", '<', "person", '/>'] 226 | """ 227 | def generate_iodata(any, options \\ []), do: format(any, 0, options) 228 | 229 | defp format(:xml_decl, 0, options) do 230 | encoding = Keyword.get(options, :encoding, "UTF-8") 231 | 232 | standalone = 233 | case Keyword.get(options, :standalone, nil) do 234 | true -> ~s| standalone="yes"| 235 | false -> ~s| standalone="no"| 236 | nil -> "" 237 | end 238 | 239 | [~c""] 240 | end 241 | 242 | defp format({:doctype, {:system, name, system}}, 0, _options), 243 | do: [~c""] 244 | 245 | defp format({:doctype, {:public, name, public, system}}, 0, _options), 246 | do: [ 247 | ~c"" 254 | ] 255 | 256 | defp format(string, level, options) when is_bitstring(string), 257 | do: format({nil, nil, string}, level, options) 258 | 259 | defp format(list, level, options) when is_list(list) do 260 | format_children(list, level, options) 261 | end 262 | 263 | defp format({nil, nil, content}, level, options) when is_bitstring(content), 264 | do: [indent(level, options), format_content(content, level, options)] 265 | 266 | defp format({nil, nil, {:iodata, iodata}}, _level, _options), do: iodata 267 | 268 | defp format({name, attrs, content}, level, options) 269 | when is_blank_attrs(attrs) and is_blank_list(content), 270 | do: [indent(level, options), ~c"<", to_string(name), ~c"/>"] 271 | 272 | defp format({name, attrs, content}, level, options) when is_blank_list(content), 273 | do: [indent(level, options), ~c"<", to_string(name), ~c" ", format_attributes(attrs), ~c"/>"] 274 | 275 | defp format({name, attrs, content}, level, options) 276 | when is_blank_attrs(attrs) and not is_list(content), 277 | do: [ 278 | indent(level, options), 279 | ~c"<", 280 | to_string(name), 281 | ~c">", 282 | format_content(content, level + 1, options), 283 | ~c"" 286 | ] 287 | 288 | defp format({name, attrs, content}, level, options) 289 | when is_blank_attrs(attrs) and is_list(content) do 290 | format_char = formatter(options).line_break() 291 | 292 | [ 293 | indent(level, options), 294 | ~c"<", 295 | to_string(name), 296 | ~c">", 297 | format_content(content, level + 1, options), 298 | format_char, 299 | indent(level, options), 300 | ~c"" 303 | ] 304 | end 305 | 306 | defp format({name, attrs, content}, level, options) 307 | when not is_blank_attrs(attrs) and not is_list(content), 308 | do: [ 309 | indent(level, options), 310 | ~c"<", 311 | to_string(name), 312 | ~c" ", 313 | format_attributes(attrs), 314 | ~c">", 315 | format_content(content, level + 1, options), 316 | ~c"" 319 | ] 320 | 321 | defp format({name, attrs, content}, level, options) 322 | when not is_blank_attrs(attrs) and is_list(content) do 323 | format_char = formatter(options).line_break() 324 | 325 | [ 326 | indent(level, options), 327 | ~c"<", 328 | to_string(name), 329 | ~c" ", 330 | format_attributes(attrs), 331 | ~c">", 332 | format_content(content, level + 1, options), 333 | format_char, 334 | indent(level, options), 335 | ~c"" 338 | ] 339 | end 340 | 341 | defp format_children(list, level, options) when is_list(list) do 342 | line_break = formatter(options).line_break() 343 | 344 | {result, _} = 345 | Enum.flat_map_reduce(list, 0, fn 346 | element, count when is_blank_list(element) -> 347 | {[], count} 348 | 349 | element, count -> 350 | if line_break == "" or count == 0 do 351 | {[format(element, level, options)], count + 1} 352 | else 353 | {[line_break, format(element, level, options)], count + 1} 354 | end 355 | end) 356 | 357 | result 358 | end 359 | 360 | defp elements_with_prolog([first | rest]) when length(rest) > 0, 361 | do: [first_element(first) | element(rest)] 362 | 363 | defp elements_with_prolog(element_spec), 364 | do: element(element_spec) 365 | 366 | defp first_element({:doctype, args} = doctype_decl) when is_tuple(args), 367 | do: doctype_decl 368 | 369 | defp first_element(element_spec), 370 | do: element(element_spec) 371 | 372 | defp formatter(options) do 373 | case Keyword.get(options, :format) do 374 | :none -> XmlBuilder.Format.None 375 | _ -> XmlBuilder.Format.Indented 376 | end 377 | end 378 | 379 | defp format_content(children, level, options) when is_list(children) do 380 | format_char = formatter(options).line_break() 381 | [format_char, format_children(children, level, options)] 382 | end 383 | 384 | defp format_content(content, _level, _options), 385 | do: escape(content) 386 | 387 | defp format_attributes(attrs), 388 | do: 389 | map_intersperse(attrs, " ", fn {name, value} -> 390 | [to_string(name), ~c"=", quote_attribute_value(value)] 391 | end) 392 | 393 | defp indent(level, options) do 394 | formatter = formatter(options) 395 | formatter.indentation(level, options) 396 | end 397 | 398 | defp quote_attribute_value(val) when not is_bitstring(val), 399 | do: quote_attribute_value(to_string(val)) 400 | 401 | defp quote_attribute_value(val) do 402 | escape? = String.contains?(val, ["\"", "&", "<"]) 403 | 404 | case escape? do 405 | true -> [?", escape(val), ?"] 406 | false -> [?", val, ?"] 407 | end 408 | end 409 | 410 | defp escape({:iodata, iodata}), do: iodata 411 | defp escape({:safe, data}) when is_bitstring(data), do: data 412 | defp escape({:safe, data}), do: to_string(data) 413 | defp escape({:cdata, data}), do: [""] 414 | 415 | defp escape(data) when is_binary(data), 416 | do: data |> escape_string() |> to_string() 417 | 418 | defp escape(data) when not is_bitstring(data), 419 | do: data |> to_string() |> escape_string() |> to_string() 420 | 421 | defp escape_string(""), do: "" 422 | defp escape_string(<<"&"::utf8, rest::binary>>), do: escape_entity(rest) 423 | defp escape_string(<<"<"::utf8, rest::binary>>), do: ["<" | escape_string(rest)] 424 | defp escape_string(<<">"::utf8, rest::binary>>), do: [">" | escape_string(rest)] 425 | defp escape_string(<<"\""::utf8, rest::binary>>), do: [""" | escape_string(rest)] 426 | defp escape_string(<<"'"::utf8, rest::binary>>), do: ["'" | escape_string(rest)] 427 | defp escape_string(<>), do: [c | escape_string(rest)] 428 | 429 | defp escape_entity(<<"amp;"::utf8, rest::binary>>), do: ["&" | escape_string(rest)] 430 | defp escape_entity(<<"lt;"::utf8, rest::binary>>), do: ["<" | escape_string(rest)] 431 | defp escape_entity(<<"gt;"::utf8, rest::binary>>), do: [">" | escape_string(rest)] 432 | defp escape_entity(<<"quot;"::utf8, rest::binary>>), do: [""" | escape_string(rest)] 433 | defp escape_entity(<<"apos;"::utf8, rest::binary>>), do: ["'" | escape_string(rest)] 434 | defp escape_entity(rest), do: ["&" | escape_string(rest)] 435 | 436 | # Remove when support for Elixir Enum.map(mapper) |> Enum.intersperse(separator) 444 | end 445 | end 446 | -------------------------------------------------------------------------------- /lib/xml_builder/format/indented.ex: -------------------------------------------------------------------------------- 1 | defmodule XmlBuilder.Format.Indented do 2 | @moduledoc "Documentation for #{__MODULE__}" 3 | 4 | def indentation(level, options) do 5 | whitespace = Keyword.get(options, :whitespace, " ") 6 | 7 | String.duplicate(whitespace, level) 8 | end 9 | 10 | def line_break, do: "\n" 11 | end 12 | -------------------------------------------------------------------------------- /lib/xml_builder/format/none.ex: -------------------------------------------------------------------------------- 1 | defmodule XmlBuilder.Format.None do 2 | @moduledoc "Documentation for #{__MODULE__}" 3 | 4 | def indentation(_level, _options), do: "" 5 | 6 | def line_break, do: "" 7 | end 8 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule XmlBuilder.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/joshnuss/xml_builder" 5 | 6 | def project do 7 | [ 8 | app: :xml_builder, 9 | version: "2.4.0", 10 | elixir: "~> 1.12", 11 | deps: deps(), 12 | docs: docs(), 13 | package: [ 14 | maintainers: ["Joshua Nussbaum"], 15 | licenses: ["MIT"], 16 | links: %{GitHub: @source_url} 17 | ], 18 | description: "XML builder for Elixir" 19 | ] 20 | end 21 | 22 | def application do 23 | [] 24 | end 25 | 26 | defp deps do 27 | [ 28 | {:credo, "~> 1.7.5", only: [:dev, :test], runtime: false}, 29 | {:ex_doc, github: "elixir-lang/ex_doc", only: :dev} 30 | ] 31 | end 32 | 33 | defp docs do 34 | [ 35 | main: "readme", 36 | source_url: @source_url, 37 | extras: ["README.md"] 38 | ] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, 4 | "earmark": {:hex, :earmark, "1.4.0", "397e750b879df18198afc66505ca87ecf6a96645545585899f6185178433cc09", [:mix], [], "hexpm", "4bedcec35de03b5f559fd2386be24d08f7637c374d3a85d3fe0911eecdae838a"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 6 | "ex_doc": {:git, "https://github.com/elixir-lang/ex_doc.git", "72bb75889d0eef8df71cbf40bfd9d7bb8ed16120", []}, 7 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 8 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 9 | "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, 10 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [: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", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 11 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.5", "e0ff5a7c708dda34311f7522a8758e23bfcd7d8d8068dc312b5eb41c6fd76eba", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "94d2e986428585a21516d7d7149781480013c56e30c6a233534bedf38867a59a"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 13 | } 14 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/xml_builder_test.exs: -------------------------------------------------------------------------------- 1 | defmodule XmlBuilderTest do 2 | use ExUnit.Case 3 | doctest XmlBuilder 4 | 5 | import ExUnit.CaptureIO 6 | 7 | import XmlBuilder, 8 | only: [doc: 1, doc: 2, doc: 3, document: 1, document: 2, document: 3, doctype: 2] 9 | 10 | test "empty element" do 11 | assert document(:person) == [:xml_decl, {:person, nil, nil}] 12 | end 13 | 14 | test "document with DOCTYPE declaration and a system identifier" do 15 | assert document([doctype("greeting", system: "hello.dtd"), {:greeting, "Hello, world!"}]) == 16 | [ 17 | :xml_decl, 18 | {:doctype, {:system, "greeting", "hello.dtd"}}, 19 | {:greeting, nil, "Hello, world!"} 20 | ] 21 | end 22 | 23 | test "document with DOCTYPE declaration and a public identifier" do 24 | assert document([ 25 | doctype( 26 | "html", 27 | public: [ 28 | "-//W3C//DTD XHTML 1.0 Transitional//EN", 29 | "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" 30 | ] 31 | ), 32 | {:html, "Hello, world!"} 33 | ]) == 34 | [ 35 | :xml_decl, 36 | {:doctype, 37 | {:public, "html", "-//W3C//DTD XHTML 1.0 Transitional//EN", 38 | "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"}}, 39 | {:html, nil, "Hello, world!"} 40 | ] 41 | end 42 | 43 | test "document 2 parameters" do 44 | assert document(:person, "Josh") == [:xml_decl, {:person, nil, "Josh"}] 45 | assert document(:person, %{id: 1}) == [:xml_decl, {:person, %{id: 1}, nil}] 46 | end 47 | 48 | test "document with 3 parameters" do 49 | assert document(:person, %{id: 1}, "Josh") == [:xml_decl, {:person, %{id: 1}, "Josh"}] 50 | end 51 | 52 | test "doc with empty element" do 53 | warning = 54 | capture_io(:stderr, fn -> 55 | assert doc(:person) == ~s|\n| 56 | end) 57 | 58 | assert warning =~ "doc/1 is deprecated. Use document/1 with generate/1 instead." 59 | end 60 | 61 | test "doc with DOCTYPE declaration and a system identifier" do 62 | warning = 63 | capture_io(:stderr, fn -> 64 | assert doc([doctype("greeting", system: "hello.dtd"), {:greeting, "Hello, world!"}]) == 65 | ~s|\n\nHello, world!| 66 | end) 67 | 68 | assert warning =~ "doc/1 is deprecated. Use document/1 with generate/1 instead." 69 | end 70 | 71 | test "doc with DOCTYPE declaration and a public identifier" do 72 | warning = 73 | capture_io(:stderr, fn -> 74 | assert doc([ 75 | doctype( 76 | "html", 77 | public: [ 78 | "-//W3C//DTD XHTML 1.0 Transitional//EN", 79 | "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd" 80 | ] 81 | ), 82 | {:html, "Hello, world!"} 83 | ]) == 84 | ~s|\n\nHello, world!| 85 | end) 86 | 87 | assert warning =~ "doc/1 is deprecated. Use document/1 with generate/1 instead." 88 | end 89 | 90 | describe "#generate with options" do 91 | defp input, 92 | do: {:level1, nil, [{:level2, nil, "test_value"}]} 93 | 94 | test "when format = none, tab and nl formatting is not used" do 95 | expectation = "test_value" 96 | assert XmlBuilder.generate(input(), format: :none) == expectation 97 | end 98 | 99 | test "whitespace character option is used" do 100 | expectation = "\n\ttest_value\n" 101 | assert XmlBuilder.generate(input(), whitespace: "\t") == expectation 102 | end 103 | 104 | test "encoding defaults to UTF-8" do 105 | expectation = ~s|| 106 | assert XmlBuilder.generate(:xml_decl) == expectation 107 | end 108 | 109 | test "encoding option is used" do 110 | expectation = ~s|| 111 | assert XmlBuilder.generate(:xml_decl, encoding: "ISO-8859-1") == expectation 112 | end 113 | 114 | test "encoding option works with other options" do 115 | xml = 116 | [XmlBuilder.element(:oldschool, [])] 117 | |> XmlBuilder.document() 118 | |> XmlBuilder.generate(format: :indent, encoding: "ISO-8859-1") 119 | 120 | expectation = ~s|\n| 121 | assert xml == expectation 122 | end 123 | 124 | test "standalone option is used" do 125 | expectation = ~s|| 126 | assert XmlBuilder.generate(:xml_decl, standalone: true) == expectation 127 | end 128 | 129 | test "standalone option is used with false value" do 130 | expectation = ~s|| 131 | assert XmlBuilder.generate(:xml_decl, standalone: false) == expectation 132 | end 133 | 134 | test "standalone option is omitted" do 135 | expectation = ~s|| 136 | assert XmlBuilder.generate(:xml_decl) == expectation 137 | end 138 | 139 | test "standalone option works with other options" do 140 | xml = 141 | [XmlBuilder.element(:standaloneOldschool, [])] 142 | |> XmlBuilder.document() 143 | |> XmlBuilder.generate(format: :indent, encoding: "ISO-8859-1", standalone: true) 144 | 145 | expectation = 146 | ~s|\n| 147 | 148 | assert xml == expectation 149 | end 150 | end 151 | 152 | test "element with content" do 153 | warning = 154 | capture_io(:stderr, fn -> 155 | assert doc(:person, "Josh") == 156 | ~s|\nJosh| 157 | end) 158 | 159 | assert warning =~ "doc/2 is deprecated. Use document/2 with generate/1 instead." 160 | end 161 | 162 | test "element with attributes" do 163 | warning = 164 | capture_io(:stderr, fn -> 165 | assert doc(:person, %{occupation: "Developer", city: "Montreal"}) in [ 166 | ~s|\n|, 167 | ~s|\n| 168 | ] 169 | 170 | assert doc(:person, %{}) == ~s|\n| 171 | end) 172 | 173 | assert warning =~ "doc/2 is deprecated. Use document/2 with generate/1 instead." 174 | end 175 | 176 | test "element with attributes and content" do 177 | warning = 178 | capture_io(:stderr, fn -> 179 | assert doc(:person, %{occupation: "Developer", city: "Montreal"}, "Josh") in [ 180 | ~s|\nJosh|, 181 | ~s|\nJosh| 182 | ] 183 | 184 | assert doc(:person, %{occupation: "Developer", city: "Montreal"}, nil) in [ 185 | ~s|\n|, 186 | ~s|\n| 187 | ] 188 | 189 | assert doc(:person, %{}, "Josh") == 190 | ~s|\nJosh| 191 | 192 | assert doc(:person, %{}, nil) == ~s|\n| 193 | end) 194 | 195 | assert warning =~ "doc/3 is deprecated. Use document/3 with generate/1 instead." 196 | end 197 | 198 | test "element with ordered attributes" do 199 | warning = 200 | capture_io(:stderr, fn -> 201 | assert doc(:person, [occupation: "Developer", city: "Montreal"], "Josh") == 202 | ~s|\nJosh| 203 | 204 | assert doc(:person, [occupation: "Developer", city: "Montreal"], nil) == 205 | ~s|\n| 206 | 207 | assert doc(:person, [occupation: "Developer", city: "Montreal"], nil) == 208 | ~s|\n| 209 | 210 | assert doc(:person, [], "Josh") == 211 | ~s|\nJosh| 212 | 213 | assert doc(:person, [], nil) == ~s|\n| 214 | end) 215 | 216 | assert warning =~ "doc/3 is deprecated. Use document/3 with generate/1 instead." 217 | end 218 | 219 | test "element with children" do 220 | warning = 221 | capture_io(:stderr, fn -> 222 | assert doc(:person, [{:name, %{id: 123}, "Josh"}]) == 223 | ~s|\n\n Josh\n| 224 | 225 | assert doc(:person, [{:first_name, "Josh"}, {:last_name, "Nussbaum"}]) == 226 | ~s|\n\n Josh\n Nussbaum\n| 227 | end) 228 | 229 | assert warning =~ "doc/2 is deprecated. Use document/2 with generate/1 instead." 230 | end 231 | 232 | test "element with attributes and children" do 233 | warning = 234 | capture_io(:stderr, fn -> 235 | assert doc(:person, %{id: 123}, [{:name, "Josh"}]) == 236 | ~s|\n\n Josh\n| 237 | 238 | assert doc(:person, %{id: 123}, [{:first_name, "Josh"}, {:last_name, "Nussbaum"}]) == 239 | ~s|\n\n Josh\n Nussbaum\n| 240 | end) 241 | 242 | assert warning =~ "doc/3 is deprecated. Use document/3 with generate/1 instead." 243 | end 244 | 245 | test "element with text content" do 246 | warning = 247 | capture_io(:stderr, fn -> 248 | assert doc(:person, ["TextNode", {:name, %{id: 123}, "Josh"}, "TextNode"]) == 249 | ~s|\n\n TextNode\n Josh\n TextNode\n| 250 | end) 251 | 252 | assert warning =~ "doc/2 is deprecated. Use document/2 with generate/1 instead." 253 | end 254 | 255 | test "children elements" do 256 | warning = 257 | capture_io(:stderr, fn -> 258 | assert doc([{:name, %{id: 123}, "Josh"}]) == 259 | ~s|\nJosh| 260 | 261 | assert doc([{:first_name, "Josh"}, {:last_name, "Nussbaum"}]) == 262 | ~s|\nJosh\nNussbaum| 263 | 264 | assert doc([:first_name, :middle_name, :last_name]) == 265 | ~s|\n\n\n| 266 | 267 | assert doc([:first_name, nil, :last_name]) == 268 | ~s|\n\n| 269 | end) 270 | 271 | assert warning =~ "doc/1 is deprecated. Use document/1 with generate/1 instead." 272 | end 273 | 274 | test "quoting and escaping attributes" do 275 | assert element(:person, %{height: 12}) == ~s|| 276 | assert element(:person, %{height: ~s|10'|}) == ~s|| 277 | assert element(:person, %{height: ~s|10"|}) == ~s|| 278 | assert element(:person, %{height: ~s|<10'5"|}) == ~s|| 279 | assert element(:person, %{height: ~s|<10|}) == ~s|| 280 | end 281 | 282 | test "escaping content" do 283 | assert element(:person, "Josh") == "Josh" 284 | assert element(:person, "") == "<Josh>" 285 | 286 | assert element(:paragraph, ["I <3 Elixir ", XmlBuilder.element(:bold, "& XmlBuilder")]) == 287 | "\n I <3 Elixir \n & XmlBuilder\n" 288 | 289 | assert element(:data, ~s|1 <> 2 & 2 <> 3 "'"'|) == 290 | "1 <> 2 & 2 <> 3 "'"'" 291 | 292 | assert element(:data, ~s|><"'&|) == 293 | "><"'&" 294 | end 295 | 296 | test "wrap content inside cdata and skip escaping" do 297 | assert element(:person, {:cdata, "john & "}) == 298 | "]]>" 299 | end 300 | 301 | test "wrap content inside safe and skip escaping" do 302 | assert element(:person, {:safe, "john & "}) == "john & " 303 | end 304 | 305 | test "wrap content inside iodata and skip escaping and binary conversion" do 306 | assert element(:person, {:iodata, [element(:name, "john"), element(:age, "12")]}) == 307 | "john12" 308 | 309 | assert element(:person, {:iodata, ["test", ?i, "ng 123"]}) == "testing 123" 310 | 311 | assert XmlBuilder.element(:person, {:iodata, ["test", ?i, "ng 123"]}) 312 | |> XmlBuilder.generate_iodata() == 313 | ["", ~c"<", "person", ~c">", ["test", ?i, "ng 123"], ~c""] 314 | end 315 | 316 | test "multi level indentation" do 317 | warning = 318 | capture_io(:stderr, fn -> 319 | assert doc(person: [first: "Josh", last: "Nussbaum"]) == 320 | ~s|\n\n Josh\n Nussbaum\n| 321 | end) 322 | 323 | assert warning =~ "doc/1 is deprecated. Use document/1 with generate/1 instead." 324 | end 325 | 326 | test "removal of empty child elements" do 327 | assert {:person, nil, [nil, {:first, nil, "Josh"}, [], {:last, nil, "Nussbaum"}]} 328 | |> XmlBuilder.generate() == 329 | ~s|\n Josh\n Nussbaum\n| 330 | end 331 | 332 | def element(name, arg), 333 | do: XmlBuilder.element(name, arg) |> XmlBuilder.generate() 334 | 335 | def element(name, attrs, content), 336 | do: XmlBuilder.element(name, attrs, content) |> XmlBuilder.generate() 337 | end 338 | --------------------------------------------------------------------------------