defmodule XmlBuilder do @moduledoc """ A module for generating XML ## Examples iex> XmlBuilder.document(:person) |> XmlBuilder.generate "\\n" iex> XmlBuilder.document(:person, "Josh") |> XmlBuilder.generate "\\nJosh" iex> XmlBuilder.document(:person) |> XmlBuilder.generate(format: :none) "" iex> XmlBuilder.element(:person, "Josh") |> XmlBuilder.generate "Josh" iex> XmlBuilder.element(:person, %{occupation: "Developer"}, "Josh") |> XmlBuilder.generate "Josh" """ defmacrop is_blank_attrs(attrs) do quote do: is_blank_map(unquote(attrs)) or is_blank_list(unquote(attrs)) end defmacrop is_blank_list(list) do quote do: is_nil(unquote(list)) or unquote(list) == [] end defmacrop is_blank_map(map) do quote do: is_nil(unquote(map)) or unquote(map) == %{} end @doc """ Generate an XML document. Returns a `binary`. ## Examples iex> XmlBuilder.document(:person) |> XmlBuilder.generate "\\n" iex> XmlBuilder.document(:person, %{id: 1}) |> XmlBuilder.generate "\\n" iex> XmlBuilder.document(:person, %{id: 1}, "some data") |> XmlBuilder.generate "\\nsome data" """ def document(elements), do: [:xml_decl | elements_with_prolog(elements) |> List.wrap()] def document(name, attrs_or_content), do: [:xml_decl | [element(name, attrs_or_content)]] def document(name, attrs, content), do: [:xml_decl | [element(name, attrs, content)]] @doc false def doc(elements) do IO.warn("doc/1 is deprecated. Use document/1 with generate/1 instead.") [:xml_decl | elements_with_prolog(elements) |> List.wrap()] |> generate end @doc false def doc(name, attrs_or_content) do IO.warn("doc/2 is deprecated. Use document/2 with generate/1 instead.") [:xml_decl | [element(name, attrs_or_content)]] |> generate end @doc false def doc(name, attrs, content) do IO.warn("doc/3 is deprecated. Use document/3 with generate/1 instead.") [:xml_decl | [element(name, attrs, content)]] |> generate end @doc """ Create an XML element. Returns a `tuple` in the format `{name, attributes, content | list}`. ## Examples iex> XmlBuilder.element(:person) {:person, nil, nil} iex> XmlBuilder.element(:person, "data") {:person, nil, "data"} iex> XmlBuilder.element(:person, %{id: 1}) {:person, %{id: 1}, nil} iex> XmlBuilder.element(:person, %{id: 1}, "data") {:person, %{id: 1}, "data"} iex> XmlBuilder.element(:person, %{id: 1}, [XmlBuilder.element(:first, "Steve"), XmlBuilder.element(:last, "Jobs")]) {:person, %{id: 1}, [ {:first, nil, "Steve"}, {:last, nil, "Jobs"} ]} """ def element(name) when is_bitstring(name), do: element({nil, nil, name}) def element({:iodata, _data} = iodata), do: element({nil, nil, iodata}) def element(name) when is_bitstring(name) or is_atom(name), do: element({name}) def element(list) when is_list(list), do: list |> Enum.reject(&is_nil/1) |> Enum.map(&element/1) def element({name}), do: element({name, nil, nil}) def element({name, attrs}) when is_map(attrs), do: element({name, attrs, nil}) def element({name, content}), do: element({name, nil, content}) def element({name, attrs, content}) when is_list(content), do: {name, attrs, element(content)} def element({name, attrs, content}), do: {name, attrs, content} def element(name, attrs) when is_map(attrs), do: element({name, attrs, nil}) def element(name, content), do: element({name, nil, content}) def element(name, attrs, content), do: element({name, attrs, content}) @doc """ Creates a DOCTYPE declaration with a system or public identifier. ## System Example Returns a `tuple` in the format `{:doctype, {:system, name, system_identifier}}`. ```elixir import XmlBuilder document([ doctype("greeting", system: "hello.dtd"), element(:person, "Josh") ]) |> generate ``` Outputs ```xml Josh ``` ## Public Example Returns a `tuple` in the format `{:doctype, {:public, name, public_identifier, system_identifier}}`. ```elixir import XmlBuilder document([ doctype("html", public: ["-//W3C//DTD XHTML 1.0 Transitional//EN", "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"]), element(:html, "Hello, world!") ]) |> generate ``` Outputs ```xml Hello, world! ``` """ def doctype(name, [{:system, system_identifier}]), do: {:doctype, {:system, name, system_identifier}} def doctype(name, [{:public, [public_identifier, system_identifier]}]), do: {:doctype, {:public, name, public_identifier, system_identifier}} @doc """ Generate a binary from an XML tree Returns a `binary`. ## Examples iex> XmlBuilder.generate(XmlBuilder.element(:person)) "" iex> XmlBuilder.generate({:person, %{id: 1}, "Steve Jobs"}) "Steve Jobs" iex> XmlBuilder.generate({:name, nil, [{:first, nil, "Steve"}]}, format: :none) "Steve" iex> XmlBuilder.generate({:name, nil, [{:first, nil, "Steve"}]}, whitespace: "") "\\nSteve\\n" iex> XmlBuilder.generate({:name, nil, [{:first, nil, "Steve"}]}) "\\n Steve\\n" iex> XmlBuilder.generate(:xml_decl, encoding: "ISO-8859-1") ~s|| """ def generate(any, options \\ []), do: format(any, 0, options) |> IO.iodata_to_binary() @doc """ Similar to `generate/2`, but returns `iodata` instead of a `binary`. ## Examples iex> XmlBuilder.generate_iodata(XmlBuilder.element(:person)) ["", '<', "person", '/>'] """ def generate_iodata(any, options \\ []), do: format(any, 0, options) defp format(:xml_decl, 0, options) do encoding = Keyword.get(options, :encoding, "UTF-8") standalone = case Keyword.get(options, :standalone, nil) do true -> ~s| standalone="yes"| false -> ~s| standalone="no"| nil -> "" end [~c""] end defp format({:doctype, {:system, name, system}}, 0, _options), do: [~c""] defp format({:doctype, {:public, name, public, system}}, 0, _options), do: [ ~c"" ] defp format(string, level, options) when is_bitstring(string), do: format({nil, nil, string}, level, options) defp format(list, level, options) when is_list(list) do format_children(list, level, options) end defp format({nil, nil, content}, level, options) when is_bitstring(content), do: [indent(level, options), format_content(content, level, options)] defp format({nil, nil, {:iodata, iodata}}, _level, _options), do: iodata defp format({name, attrs, content}, level, options) when is_blank_attrs(attrs) and is_blank_list(content), do: [indent(level, options), ~c"<", to_string(name), ~c"/>"] defp format({name, attrs, content}, level, options) when is_blank_list(content), do: [indent(level, options), ~c"<", to_string(name), ~c" ", format_attributes(attrs), ~c"/>"] defp format({name, attrs, content}, level, options) when is_blank_attrs(attrs) and not is_list(content), do: [ indent(level, options), ~c"<", to_string(name), ~c">", format_content(content, level + 1, options), ~c"" ] defp format({name, attrs, content}, level, options) when is_blank_attrs(attrs) and is_list(content) do format_char = formatter(options).line_break() [ indent(level, options), ~c"<", to_string(name), ~c">", format_content(content, level + 1, options), format_char, indent(level, options), ~c"" ] end defp format({name, attrs, content}, level, options) when not is_blank_attrs(attrs) and not is_list(content), do: [ indent(level, options), ~c"<", to_string(name), ~c" ", format_attributes(attrs), ~c">", format_content(content, level + 1, options), ~c"" ] defp format({name, attrs, content}, level, options) when not is_blank_attrs(attrs) and is_list(content) do format_char = formatter(options).line_break() [ indent(level, options), ~c"<", to_string(name), ~c" ", format_attributes(attrs), ~c">", format_content(content, level + 1, options), format_char, indent(level, options), ~c"" ] end defp format_children(list, level, options) when is_list(list) do line_break = formatter(options).line_break() {result, _} = Enum.flat_map_reduce(list, 0, fn element, count when is_blank_list(element) -> {[], count} element, count -> if line_break == "" or count == 0 do {[format(element, level, options)], count + 1} else {[line_break, format(element, level, options)], count + 1} end end) result end defp elements_with_prolog([first | rest]) when length(rest) > 0, do: [first_element(first) | element(rest)] defp elements_with_prolog(element_spec), do: element(element_spec) defp first_element({:doctype, args} = doctype_decl) when is_tuple(args), do: doctype_decl defp first_element(element_spec), do: element(element_spec) defp formatter(options) do case Keyword.get(options, :format) do :none -> XmlBuilder.Format.None _ -> XmlBuilder.Format.Indented end end defp format_content(children, level, options) when is_list(children) do format_char = formatter(options).line_break() [format_char, format_children(children, level, options)] end defp format_content(content, _level, _options), do: escape(content) defp format_attributes(attrs), do: map_intersperse(attrs, " ", fn {name, value} -> [to_string(name), ~c"=", quote_attribute_value(value)] end) defp indent(level, options) do formatter = formatter(options) formatter.indentation(level, options) end defp quote_attribute_value(val) when not is_bitstring(val), do: quote_attribute_value(to_string(val)) defp quote_attribute_value(val) do escape? = String.contains?(val, ["\"", "&", "<"]) case escape? do true -> [?", escape(val), ?"] false -> [?", val, ?"] end end defp escape({:iodata, iodata}), do: iodata defp escape({:safe, data}) when is_bitstring(data), do: data defp escape({:safe, data}), do: to_string(data) defp escape({:cdata, data}), do: [""] defp escape(data) when is_binary(data), do: data |> escape_string() |> to_string() defp escape(data) when not is_bitstring(data), do: data |> to_string() |> escape_string() |> to_string() defp escape_string(""), do: "" defp escape_string(<<"&"::utf8, rest::binary>>), do: escape_entity(rest) defp escape_string(<<"<"::utf8, rest::binary>>), do: ["<" | escape_string(rest)] defp escape_string(<<">"::utf8, rest::binary>>), do: [">" | escape_string(rest)] defp escape_string(<<"\""::utf8, rest::binary>>), do: [""" | escape_string(rest)] defp escape_string(<<"'"::utf8, rest::binary>>), do: ["'" | escape_string(rest)] defp escape_string(<>), do: [c | escape_string(rest)] defp escape_entity(<<"amp;"::utf8, rest::binary>>), do: ["&" | escape_string(rest)] defp escape_entity(<<"lt;"::utf8, rest::binary>>), do: ["<" | escape_string(rest)] defp escape_entity(<<"gt;"::utf8, rest::binary>>), do: [">" | escape_string(rest)] defp escape_entity(<<"quot;"::utf8, rest::binary>>), do: [""" | escape_string(rest)] defp escape_entity(<<"apos;"::utf8, rest::binary>>), do: ["'" | escape_string(rest)] defp escape_entity(rest), do: ["&" | escape_string(rest)] # Remove when support for Elixir Enum.map(mapper) |> Enum.intersperse(separator) end end