Skip to content

Commit c684516

Browse files
authored
Merge pull request #51 from mediapress-ltd/master
Add `generate_iodata` and performance improvements
2 parents 6d51faf + 6024173 commit c684516

File tree

4 files changed

+216
-75
lines changed

4 files changed

+216
-75
lines changed

.github/workflows/elixir.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ jobs:
88
runs-on: ubuntu-latest
99
strategy:
1010
matrix:
11-
otp: ['22.3.4']
11+
otp: ['22.3']
1212
elixir: ['1.11.2', '1.10.4', '1.9.4', '1.8.2', '1.7.4', '1.6.6']
1313
steps:
1414
- uses: actions/checkout@v2
15-
- uses: actions/setup-elixir@v1
15+
- uses: erlef/setup-beam@v1
1616
with:
1717
otp-version: ${{matrix.otp}}
1818
elixir-version: ${{matrix.elixir}}
1919
- run: mix deps.get
2020
- run: mix compile --warnings-as-errors
2121
- run: mix format --check-formatted
2222
- run: mix credo --strict
23-
- run: mix test
23+
- run: mix test

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,49 @@ Outputs.
185185
<oldschool/>
186186
```
187187

188+
### Using `iodata()` directly
189+
190+
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.
191+
192+
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:
193+
194+
```elixir
195+
import XmlBuilder
196+
197+
entries = [%{title: "Test", url: "https://example.org/"}]
198+
199+
# Generate static author data upfront
200+
author = generate_iodata(element(:author, [
201+
element(:name, "John Doe"),
202+
element(:uri, "https://example.org/")
203+
]))
204+
205+
file = File.open!("path/to/file", [:raw])
206+
207+
for entry <- entries do
208+
iodata =
209+
generate_iodata(element(:entry, [
210+
# Reuse the static pre-generated fields as-is
211+
{:iodata, author},
212+
213+
# Dynamic elements are generated for each entry
214+
element(:title, entry.title),
215+
element(:link, entry.url)
216+
]))
217+
218+
IO.binwrite(file, iodata)
219+
end
220+
```
221+
222+
### Escaping
223+
224+
XmlBuilder offers 3 distinct ways to control how content of tags is escaped and handled:
225+
226+
- By default, any content is escaped, replacing reserved characters (`& " ' < >`) with their equivalent entity (`&amp;` etc.)
227+
- If content is wrapped in `{:cdata, cdata}`, the content in `cdata` is wrapped with `<![CDATA[...]]>`, and not escaped. You should make sure the content itself does not contain `]]>`.
228+
- 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).
229+
- 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.
230+
188231
### Standalone
189232

190233
Should you need `standalone="yes"` in the XML declaration, you can pass `standalone: true` as option to the `generate/2` call.

lib/xml_builder.ex

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ defmodule XmlBuilder do
2525
end
2626

2727
defmacrop is_blank_list(list) do
28-
quote do: is_nil(unquote(list)) or (is_list(unquote(list)) and unquote(list) == [])
28+
quote do: is_nil(unquote(list)) or unquote(list) == []
2929
end
3030

3131
defmacrop is_blank_map(map) do
32-
quote do: is_nil(unquote(map)) or (is_map(unquote(map)) and map_size(unquote(map)) == 0)
32+
quote do: is_nil(unquote(map)) or unquote(map) == %{}
3333
end
3434

3535
@doc """
@@ -103,6 +103,9 @@ defmodule XmlBuilder do
103103
def element(name) when is_bitstring(name),
104104
do: element({nil, nil, name})
105105

106+
def element({:iodata, _data} = iodata),
107+
do: element({nil, nil, iodata})
108+
106109
def element(name) when is_bitstring(name) or is_atom(name),
107110
do: element({name})
108111

@@ -211,7 +214,17 @@ defmodule XmlBuilder do
211214
~s|<?xml version="1.0" encoding="ISO-8859-1"?>|
212215
"""
213216
def generate(any, options \\ []),
214-
do: format(any, 0, options) |> IO.chardata_to_string()
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)
215228

216229
defp format(:xml_decl, 0, options) do
217230
encoding = Keyword.get(options, :encoding, "UTF-8")
@@ -223,7 +236,7 @@ defmodule XmlBuilder do
223236
nil -> ""
224237
end
225238

226-
~s|<?xml version="1.0" encoding="#{encoding}"#{standalone}?>|
239+
['<?xml version="1.0" encoding="', to_string(encoding), ?", standalone, '?>']
227240
end
228241

229242
defp format({:doctype, {:system, name, system}}, 0, _options),
@@ -245,12 +258,14 @@ defmodule XmlBuilder do
245258

246259
defp format(list, level, options) when is_list(list) do
247260
formatter = formatter(options)
248-
list |> Enum.map(&format(&1, level, options)) |> Enum.intersperse(formatter.line_break())
261+
map_intersperse(list, formatter.line_break(), &format(&1, level, options))
249262
end
250263

251264
defp format({nil, nil, name}, level, options) when is_bitstring(name),
252265
do: [indent(level, options), to_string(name)]
253266

267+
defp format({nil, nil, {:iodata, iodata}}, _level, _options), do: iodata
268+
254269
defp format({name, attrs, content}, level, options)
255270
when is_blank_attrs(attrs) and is_blank_list(content),
256271
do: [indent(level, options), '<', to_string(name), '/>']
@@ -345,15 +360,15 @@ defmodule XmlBuilder do
345360

346361
defp format_content(children, level, options) when is_list(children) do
347362
format_char = formatter(options).line_break()
348-
[format_char, Enum.map_join(children, format_char, &format(&1, level, options))]
363+
[format_char, map_intersperse(children, format_char, &format(&1, level, options))]
349364
end
350365

351366
defp format_content(content, _level, _options),
352367
do: escape(content)
353368

354369
defp format_attributes(attrs),
355370
do:
356-
Enum.map_join(attrs, " ", fn {name, value} ->
371+
map_intersperse(attrs, " ", fn {name, value} ->
357372
[to_string(name), '=', quote_attribute_value(value)]
358373
end)
359374

@@ -366,22 +381,17 @@ defmodule XmlBuilder do
366381
do: quote_attribute_value(to_string(val))
367382

368383
defp quote_attribute_value(val) do
369-
double = String.contains?(val, ~s|"|)
370-
single = String.contains?(val, "'")
371-
escaped = escape(val)
372-
373-
cond do
374-
double && single ->
375-
escaped |> String.replace("\"", "&quot;") |> quote_attribute_value
384+
escape? = String.contains?(val, ["\"", "&", "<"])
376385

377-
double ->
378-
"'#{escaped}'"
379-
380-
true ->
381-
~s|"#{escaped}"|
386+
case escape? do
387+
true -> [?", escape(val), ?"]
388+
false -> [?", val, ?"]
382389
end
383390
end
384391

392+
defp escape({:iodata, iodata}), do: iodata
393+
defp escape({:safe, data}) when is_bitstring(data), do: data
394+
defp escape({:safe, data}), do: to_string(data)
385395
defp escape({:cdata, data}), do: ["<![CDATA[", data, "]]>"]
386396

387397
defp escape(data) when is_binary(data),
@@ -404,4 +414,14 @@ defmodule XmlBuilder do
404414
defp escape_entity(<<"quot;"::utf8, rest::binary>>), do: ["&quot;" | escape_string(rest)]
405415
defp escape_entity(<<"apos;"::utf8, rest::binary>>), do: ["&apos;" | escape_string(rest)]
406416
defp escape_entity(rest), do: ["&amp;" | escape_string(rest)]
417+
418+
# Remove when support for Elixir <v1.10 is dropped
419+
@compile {:inline, map_intersperse: 3}
420+
if function_exported?(Enum, :map_intersperse, 3) do
421+
defp map_intersperse(enumerable, separator, mapper),
422+
do: Enum.map_intersperse(enumerable, separator, mapper)
423+
else
424+
defp map_intersperse(enumerable, separator, mapper),
425+
do: enumerable |> Enum.map(mapper) |> Enum.intersperse(separator)
426+
end
407427
end

0 commit comments

Comments
 (0)