├── .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 | [](https://github.com/joshnuss/xml_builder/actions)
5 | [](https://hex.pm/packages/xml_builder)
6 | [](https://hexdocs.pm/xml_builder/)
7 | [](https://hex.pm/packages/xml_builder)
8 | [](https://github.com/joshnuss/xml_builder/blob/master/LICENSE)
9 | [](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"",
284 | to_string(name),
285 | ~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"",
301 | to_string(name),
302 | ~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"",
317 | to_string(name),
318 | ~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"",
336 | to_string(name),
337 | ~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"", "person", ~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 |
--------------------------------------------------------------------------------