-module(xml_builderl). -export([document/1, document/2, document/3, doctype/2, elem/1, elem/2, elem/3]). -export([generate/1, generate/2, generate_iodata/1, generate_iodata/2, from_map/1]). -define(IS_BLANK_MAP(M), ((M == undefined) or (M == #{}))). -define(IS_BLANK_LIST(L), ((L == undefined) or (L == []))). -define(IS_BLANK_ATTRS(A), (?IS_BLANK_MAP(A) or ?IS_BLANK_LIST(A))). document(Elems) -> [xml_decl | wrap(elems_with_prolog(Elems))]. document(Name, AttrsOrContent) -> [xml_decl | [elem(Name, AttrsOrContent)]]. document(Name, Attrs, Content) -> [xml_decl | [elem(Name, Attrs, Content)]]. %% Chose elem instead of element since element is a BIF elem(Name) when is_binary(Name) -> elem({undefined, undefined, Name}); elem({iodata, _IoData} = IoData) -> elem({undefined, undefined, IoData}); elem(Name) when is_binary(Name) or is_atom(Name) -> elem({Name}); elem(List) when is_list(List) -> List1 = lists:filter(fun (E) -> E =/= undefined end, List), lists:map(fun elem/1, List1); elem({Name}) -> elem(Name, undefined, undefined); elem({Name, Attrs}) when is_map(Attrs) -> elem({Name, Attrs, undefined}); elem({Name, Content}) -> elem({Name, undefined, Content}); elem({Name, Attrs, Content}) when is_list(Content) -> {Name, Attrs, elem(Content)}; elem({Name, Attrs, Content}) -> {Name, Attrs, Content}. elem(Name, Attrs) when is_map(Attrs) -> elem({Name, Attrs, undefined}); elem(Name, Content) -> elem({Name, undefined, Content}). elem(Name, Attrs, Content) -> elem({Name, Attrs, Content}). doctype(Name, [{system, SystemIdentifier}]) -> {doctype, {system, Name, SystemIdentifier}}; doctype(Name, [{public, [PublicIdentifier, SystemIdentifier]}]) -> {doctype, {public, Name, PublicIdentifier, SystemIdentifier}}. elems_with_prolog([First | Rest]) when length(Rest) > 0 -> [first_elem(First) | elem(Rest)]; elems_with_prolog(ElemSpec) -> elem(ElemSpec). first_elem({doctype, Args} = DoctypeDecl) when is_tuple(Args) -> DoctypeDecl; first_elem(ElemSpec) -> elem(ElemSpec). generate(Any) -> generate(Any, #{}). generate(Any, Options) -> list_to_binary(format(Any, 0, Options)). generate_iodata(Any) -> generate_iodata(Any, #{}). generate_iodata(Any, Options) -> format(Any, 0, Options). format(xml_decl, 0, Options) -> Encoding = maps:get(encoding, Options, <<"UTF-8">>), Standalone = case maps:get(standalone, Options, undefined) of true -> " standalone=\"yes\""; false -> " standalone=\"no\""; undefined -> "" end, [""]; format({doctype, {system, Name, System}}, 0, _Options) -> [""]; format({doctype, {public, Name, Public, System}}, 0, _Options) -> [""]; format(String, Level, Options) when is_bitstring(String) -> format({undefined, undefined, String}, Level, Options); format(List, Level, Options) when is_list(List) -> Formatter = formatter(Options), map_intersperse(List, Formatter:line_break(), fun (Arg) -> format(Arg, Level, Options) end); format({undefined, undefined, Name}, Level, Options) when is_bitstring(Name) -> [indent(Level, Options), to_string(Name)]; format({undefined, undefined, {iodata, IoData}}, _Level, _Options) -> IoData; format({Name, Attrs, Content}, Level, Options) when ?IS_BLANK_ATTRS(Attrs) and ?IS_BLANK_LIST(Content) -> [indent(Level, Options), "<", to_string(Name), "/>"]; format({Name, Attrs, Content}, Level, Options) when ?IS_BLANK_LIST(Content) -> [indent(Level, Options), "<", to_string(Name), " ", format_attributes(Attrs), "/>"]; format({Name, Attrs, Content}, Level, Options) when ?IS_BLANK_ATTRS(Attrs) and not is_list(Content) -> [indent(Level, Options), "<", to_string(Name), ">", format_content(Content, Level + 1, Options), ""]; format({Name, Attrs, Content}, Level, Options) when ?IS_BLANK_ATTRS(Attrs) and is_list(Content) -> Formatter = formatter(Options), FormatChar = Formatter:line_break(), [indent(Level, Options), "<", to_string(Name), ">", format_content(Content, Level + 1, Options), FormatChar, indent(Level, Options), ""]; format({Name, Attrs, Content}, Level, Options) when not ?IS_BLANK_ATTRS(Attrs) and not is_list(Content) -> [indent(Level, Options), "<", to_string(Name), " ", format_attributes(Attrs), ">", format_content(Content, Level + 1, Options), ""]; format({Name, Attrs, Content}, Level, Options) when not ?IS_BLANK_ATTRS(Attrs) and is_list(Content) -> Formatter = formatter(Options), FormatChar = Formatter:line_break(), [indent(Level, Options), "<", to_string(Name), " ", format_attributes(Attrs), ">", format_content(Content, Level + 1, Options), FormatChar, indent(Level, Options), ""]. format_content(Children, Level, Options) when is_list(Children) -> Formatter = formatter(Options), FormatChar = Formatter:line_break(), [FormatChar, map_intersperse(Children, FormatChar, fun (Arg) -> format(Arg, Level, Options) end)]; format_content(Content, _Level, _Options) -> escape(Content). format_attributes(Attrs) -> map_intersperse(Attrs, <<" ">>, fun ({Name, Value}) -> [to_string(Name), "=", quote_attribute_value(Value)] end). indent(Level, Options) -> Formatter = formatter(Options), Formatter:indentation(Level, Options). formatter(Options) -> case maps:get(format, Options, undefined) of none -> xml_builderl_format_none; _ -> xml_builderl_format_indented end. quote_attribute_value(Val) when not is_bitstring(Val) -> quote_attribute_value(to_string(Val)); quote_attribute_value(Val) -> Escape = string_contains(Val, [<<"\"">>, <<"&">>, <<"<">>]), case Escape of true -> [$", escape(Val), $"]; false -> [$", Val, $"] end. escape({iodata, IoData}) -> IoData; escape({safe, Data}) when is_bitstring(Data) -> Data; escape({safe, Data}) -> to_string(Data); escape({cdata, Data}) -> [""]; escape(Data) when is_binary(Data) -> to_string(escape_string(Data)); escape(Data) when not is_binary(Data) -> to_string(escape_string(to_string(Data))). escape_string(<<"">>) -> <<"">>; escape_string(<<"&"/utf8, Rest/binary>>) -> escape_entity(Rest); escape_string(<<"<"/utf8, Rest/binary>>) -> ["<" | escape_string(Rest)]; escape_string(<<">"/utf8, Rest/binary>>) -> [">" | escape_string(Rest)]; escape_string(<<"\""/utf8, Rest/binary>>) -> [""" | escape_string(Rest)]; escape_string(<<"'"/utf8, Rest/binary>>) -> ["'" | escape_string(Rest)]; escape_string(<>) -> [C | escape_string(Rest)]. escape_entity(<<"amp;"/utf8, Rest/binary>>) -> ["&" | escape_string(Rest)]; escape_entity(<<"lt;"/utf8, Rest/binary>>) -> ["<" | escape_string(Rest)]; escape_entity(<<"gt;"/utf8, Rest/binary>>) -> [">" | escape_string(Rest)]; escape_entity(<<"quot;"/utf8, Rest/binary>>) -> [""" | escape_string(Rest)]; escape_entity(<<"apos;"/utf8, Rest/binary>>) -> ["'" | escape_string(Rest)]; escape_entity(Rest) -> ["&" | escape_string(Rest)]. to_string(Term) when is_binary(Term) -> Term; to_string(Term) when is_bitstring(Term) -> Term; to_string(Term) when is_list(Term) -> list_to_binary(Term); to_string(Term) -> list_to_binary(lists:flatten(io_lib:format("~p", [Term]))). wrap(Data) when is_list(Data) -> Data; wrap(Data) -> [Data]. string_contains(S, Search) when is_binary(Search) -> nomatch =/= string:find(S, Search); string_contains(_S, []) -> false; string_contains(S, [H | T]) -> case string_contains(S, H) of true -> true; false -> string_contains(S, T) end. %% Ported from Elixir standard library Enum.map_intersperse map_intersperse(Enumerable, Separator, Mapper) when is_map(Enumerable) -> Reduced = lists:foldl(fun (Entry, Acc) -> case Acc == first of true -> [Mapper(Entry)]; false -> [Mapper(Entry), Separator | Acc] end end, first, maps:to_list(Enumerable)), case Reduced == first of true -> []; false -> lists:reverse(Reduced) end; map_intersperse(Enumerable, Separator, Mapper) when is_list(Enumerable) -> map_intersperse_list(Enumerable, Separator, Mapper). map_intersperse_list([], _, _) -> []; map_intersperse_list([Last], _, Mapper) -> [Mapper(Last)]; map_intersperse_list([Head | Rest], Separator, Mapper) -> [Mapper(Head), Separator | map_intersperse_list(Rest, Separator, Mapper)]. from_map(Map) when is_map(Map) -> Tags = lists:map(fun ({Key, Value}) -> build_tag(Key, Value) end, maps:to_list(Map)), generate(document(Tags)). build_tag(Key, Value) -> build_tag(Key, Value, #{}). build_tag(Key, Map, Attributes) when is_map(Map) -> case maps:get(<<"#content">>, Map, undefined) of undefined -> Tags = lists:map(fun ({K, V}) -> build_tag(K, V) end, maps:to_list(Map)), elem(Key, Attributes, Tags); Value -> FAttributes = lists:filter(fun ({K, _V}) -> string:slice(K, 0, 1) == <<"-">> end, maps:to_list(Map)), Attributes2 = maps:from_list(lists:map(fun ({K, V}) -> {string:slice(K, 1), V} end, FAttributes)), build_tag(Key, Value, Attributes2) end; build_tag(Key, Values, Attributes) when is_list(Values) -> lists:map(fun (Value) -> build_tag(Key, Value, Attributes) end, Values); build_tag(Key, Value, Attributes) -> elem(Key, Attributes, to_string(Value)). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). string_contains_test() -> ?assert(string_contains(<<"foo\"">>, <<"\"">>)), ?assert(string_contains(<<"foo&">>, <<"&">>)), ?assert(string_contains(<<"foo<">>, <<"<">>)), ?assert(string_contains(<<"foo\"">>, [<<"\"">>, <<"&">>, <<"<">>])). element_test() -> ?assertEqual({person, undefined, undefined}, elem(person)), ?assertEqual({person, undefined, <<"data">>}, elem(person, <<"data">>)), ?assertEqual({person, #{id => 1}, undefined}, elem(person, #{id => 1})), ?assertEqual({person, #{id => 1}, <<"data">>}, elem(person, #{id => 1}, <<"data">>)). element_nested_test() -> ?assertEqual({person, #{id => 1}, [{first, undefined, <<"Steve">>}, {last, undefined, <<"Jobs">>}]}, elem(person, #{id => 1}, [elem(first, <<"Steve">>), elem(last, <<"Jobs">>)])). document_test() -> ?assertEqual([xml_decl, {person, undefined, undefined}], document(person)), ?assertEqual([xml_decl, {doctype, {system, <<"greeting">>, <<"hello.dtd">>}}, {greeting, undefined, <<"Hello, World!">>}], document([doctype(<<"greeting">>, [{system, <<"hello.dtd">>}]), {greeting, <<"Hello, World!">>}])), ?assertEqual([xml_decl, {doctype, {public, <<"html">>, <<"-//W3C//DTD XHTML 1.0 Transitional//EN">>, <<"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">>}}, {html, undefined, <<"Hello, world!">>}], document([ doctype( <<"html">>, [{public, [ <<"-//W3C//DTD XHTML 1.0 Transitional//EN">>, <<"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">> ]}] ), {html, <<"Hello, world!">>} ])), ?assertEqual([xml_decl, {person, undefined, <<"Josh">>}], document(person, <<"Josh">>)), ?assertEqual([xml_decl, {person, #{id => 1}, <<"Josh">>}], document(person, #{id => 1}, <<"Josh">>)). generate_iodata_test() -> ?assertEqual([<<"">>, "<", <<"person">>, "/>"], generate_iodata(elem(person))), ?assertEqual([<<"">>, "<", <<"person">>, ">", [<<"test">>, $i, <<"ng 123">>], ">, ">"], generate_iodata(elem(person, {iodata, [<<"test">>, $i, <<"ng 123">>]}))). generate_test() -> ?assertEqual(<<"">>, generate(elem(person))), ?assertEqual(<<"Steve Jobs">>, generate({person, #{id => 1}, <<"Steve Jobs">>})), ?assertEqual(<<"Steve">>, generate({name, undefined, [{first, undefined, <<"Steve">>}]}, #{format => none})), ?assertEqual(<<"\nSteve\n">>, generate({name, undefined, [{first, undefined, <<"Steve">>}]}, #{whitespace => <<"">>})), ?assertEqual(<<"\n Steve\n">>, generate({name, undefined, [{first, undefined, <<"Steve">>}]})), ?assertEqual(<<"">>, generate(xml_decl, #{encoding => <<"ISO-8859-1">>})), Input = {level1, undefined, [{level2, undefined, <<"test_value">>}]}, ?assertEqual(<<"test_value">>, generate(Input, #{format => none})), ?assertEqual(<<"\n\ttest_value\n">>, generate(Input, #{whitespace => <<"\t">>})), ?assertEqual(<<"">>, generate(xml_decl)), ?assertEqual(<<"">>, generate(xml_decl, #{standalone => true})), ?assertEqual(<<"">>, generate(xml_decl, #{standalone => false})), ?assertEqual(<<"">>, generate(xml_decl)). document_generate_test() -> ?assertEqual(<<"\n">>, generate(document(person))), ?assertEqual(<<"\nJosh">>, generate(document(person, <<"Josh">>))), ?assertEqual(<<"">>, generate(document(person), #{format => none})), ?assertEqual(<<"\n">>, generate(document(person, #{id => 1}))), ?assertEqual(<<"\nsome data">>, generate(document(person, #{id => 1}, <<"some data">>))), ?assertEqual(<<"\n\nHello, World!">>, generate(document([doctype(<<"greeting">>, [{system, <<"hello.dtd">>}]), {greeting, <<"Hello, World!">>}]))), ?assertEqual(<<"\n\nHello, world!">>, generate(document([doctype(<<"html">>, [{public, [<<"-//W3C//DTD XHTML 1.0 Transitional//EN">>, <<"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">>]}]), {html, <<"Hello, world!">>}]))), ?assertEqual(<<"\n">>, generate(document([elem(oldschool, [])]), #{format => indent, encoding => <<"ISO-8859-1">>})), ?assertEqual(<<"\n">>, generate(document([elem(standaloneOldschool, [])]), #{format => indent, encoding => <<"ISO-8859-1">>, standalone => true})), ?assertEqual(<<"\nJosh">>, generate(document(person, <<"Josh">>))), ?assertEqual(<<"\n">>, generate(document(person, #{occupation => <<"Developer">>, city => <<"Montreal">>}))), ?assertEqual(<<"\nJosh">>, generate(document(person, #{occupation => <<"Developer">>, city => <<"Montreal">>}, <<"Josh">>))), ?assertEqual(<<"\n">>, generate(document(person, #{occupation => <<"Developer">>, city => <<"Montreal">>}, undefined))), ?assertEqual(<<"\nJosh">>, generate(document(person, #{}, <<"Josh">>))), ?assertEqual(<<"\n">>, generate(document(person, #{}, undefined))), ?assertEqual(<<"\nJosh">>, generate(document(person, [{occupation, <<"Developer">>}, {city, <<"Montreal">>}], <<"Josh">>))), ?assertEqual(<<"\n">>, generate(document(person, [{occupation, <<"Developer">>}, {city, <<"Montreal">>}], undefined))), ?assertEqual(<<"\nJosh">>, generate(document(person, [], <<"Josh">>))), ?assertEqual(<<"\n">>, generate(document(person, [], undefined))), ?assertEqual(<<"\n\n Josh\n">>, generate(document(person, [{name, #{id => 123}, <<"Josh">>}]))), ?assertEqual(<<"\n\n Josh\n Nussbaum\n">>, generate(document(person, [{first_name, <<"Josh">>}, {last_name, <<"Nussbaum">>}]))), ?assertEqual(<<"\n\n Josh\n">>, generate(document(person, #{id => 123}, [{name, <<"Josh">>}]))), ?assertEqual(<<"\n\n Josh\n Nussbaum\n">>, generate(document(person, #{id => 123}, [{first_name, <<"Josh">>}, {last_name, <<"Nussbaum">>}]))), ?assertEqual(<<"\n\n TextNode\n Josh\n TextNode\n">>, generate(document(person, [<<"TextNode">>, {name, #{id => 123}, <<"Josh">>}, <<"TextNode">>]))), ?assertEqual(<<"\nJosh">>, generate(document(name, #{id => 123}, <<"Josh">>))), ?assertEqual(<<"\nJosh\nNussbaum">>, generate(document([{first_name, <<"Josh">>}, {last_name, <<"Nussbaum">>}]))), ?assertEqual(<<"\n\n\n">>, generate(document([first_name, middle_name, last_name]))), ?assertEqual(<<"\n\n">>, generate(document([first_name, undefined, last_name]))), ?assertEqual(<<"\n\n Josh\n Nussbaum\n">>, generate(document(person, [{first, <<"Josh">>}, {last, <<"Nussbaum">>}]))). element_generate_test() -> ?assertEqual(<<"Josh">>, generate(elem(person, <<"Josh">>))), ?assertEqual(<<"Josh">>, generate(elem(person, #{occupation => <<"Developer">>}, <<"Josh">>))), ?assertEqual(<<"">>, generate(elem(person, #{height => 12}))), ?assertEqual(<<"">>, generate(elem(person, #{height => <<"10'">>}))), ?assertEqual(<<"">>, generate(elem(person, #{height => <<"10\"">>}))), ?assertEqual(<<"">>, generate(elem(person, #{height => <<"<10'5\"">>}))), ?assertEqual(<<"">>, generate(elem(person, #{height => <<"<10">>}))), ?assertEqual(<<"Josh">>, generate(elem(person, <<"Josh">>))), ?assertEqual(<<"<Josh>">>, generate(elem(person, <<"">>))), ?assertEqual(<<"1 <> 2 & 2 <> 3 "'"'">>, generate(elem(data, <<"1 <> 2 & 2 <> 3 \"'\"'">>))), ?assertEqual(<<"><"'&">>, generate(elem(data, <<"><"'&">>))), ?assertEqual(<<"]]>">>, generate(elem(person, {cdata, "john & "}))), ?assertEqual(<<"john & ">>, generate(elem(person, {safe, <<"john & ">>}))), ?assertEqual(<<"john12">>, generate(elem(person, {iodata, [generate(elem(name, <<"john">>)), generate(elem(age, <<"12">>))]}))), ?assertEqual(<<"testing 123">>, generate(elem(person, {iodata, [<<"test">>, $i, <<"ng 123">>]}))). map_to_xml_test() -> ?assertEqual(<<"\nValue1">>, from_map(#{<<"Tag1">> => <<"Value1">>})), ?assertEqual(<<"\nValue1\nValue2\nValue3">>, from_map(#{<<"Tag1">> => <<"Value1">>, <<"Tag2">> => <<"Value2">>, <<"Tag3">> => <<"Value3">>})), ?assertEqual(<<"\n\n \n Val1\n \n \n Val2\n \n \n Val3\n \n">>, from_map(#{ <<"Tags">> => #{ <<"Tag1">> => [ #{<<"Sub1">> => <<"Val1">>}, #{<<"Sub1">> => <<"Val2">>}, #{<<"Sub1">> => <<"Val3">>} ] } })), ?assertEqual(<<"\n1234\n12.15">>, from_map(#{<<"Tag1">> => 1234, <<"Tag2">> => 12.15})), ?assertEqual(<<"\ntrue\nfalse">>, from_map(#{<<"Tag1">> => true, <<"Tag2">> => false})), ?assertEqual(<<"\nsome value">>, from_map(#{<<"Tag1">> => #{<<"#content">> => <<"some value">>, <<"-id">> => 123, <<"-something">> => <<"111">>}})), ?assertEqual(<<"\n\n \n value\n \n">>, from_map(#{ <<"Tag1">> => #{ <<"#content">> => #{ <<"Tag2">> => #{ <<"Tag3">> => <<"value">> } }, <<"-id">> => 123, <<"-something">> => <<"111">> } })), ?assertEqual(<<"\n\n \n final value\n \n">>, from_map(#{ <<"Tag1">> => #{ <<"#content">> => #{ <<"Tag2">> => #{ <<"#content">> => #{ <<"Tag3">> => #{ <<"#content">> => <<"final value">>, <<"-id">> => <<"1234">>, <<"-something">> => <<"value">> } }, <<"-id">> => <<"1234">>, <<"-something">> => <<"value">> } }, <<"-id">> => <<"1234">>, <<"-something">> => <<"value">> } })), ?assertEqual(<<"\nvalue1\nvalue2">>, from_map(#{ <<"Tag1">> => #{ <<"#content">> => [ <<"value1">>, <<"value2">> ], <<"-id">> => <<"1234">>, <<"-something">> => <<"value">> } })). -endif.