# An XML builder generates valid XML.
#
# An `XML::Error` is raised if attempting to generate
# an invalid XML (for example, if invoking `end_element`
# without a matching `start_element`, or trying to use
# a non-string value as an object's field name)
class XML::Builder
private CDATA_END = "]]>"
private CDATA_ESCAPE = "]]]]>"
@box : Void*
# Creates a builder that writes to the given *io*.
def initialize(@io : IO)
@box = Box.box(io)
buffer = LibXML.xmlOutputBufferCreateIO(
->(ctx, buffer, len) {
Box(IO).unbox(ctx).write_string(Slice.new(buffer, len))
len
},
->(ctx) {
Box(IO).unbox(ctx).flush
0
},
@box,
nil
)
@writer = LibXML.xmlNewTextWriter(buffer)
end
# :nodoc:
def finalize
LibXML.xmlFreeTextWriter(@writer)
end
# Emits the start of the document.
def start_document(version = nil, encoding = nil) : Nil
call StartDocument, string_to_unsafe(version), string_to_unsafe(encoding), nil
end
# Emits the end of a document.
def end_document : Nil
call EndDocument
end
# Emits the start of the document, invokes the block,
# and then emits the end of the document.
def document(version = nil, encoding = nil, &)
start_document version, encoding
yield.tap { end_document }
end
# Emits the start of an element.
def start_element(name : String) : Nil
unsafe_name = string_to_unsafe(name)
check_valid_element_name name, unsafe_name, "element name"
call StartElement, unsafe_name
end
# Emits the start of an element with namespace info.
def start_element(prefix : String?, name : String, namespace_uri : String?) : Nil
unsafe_name = string_to_unsafe(name)
unsafe_prefix = string_to_unsafe(prefix)
check_valid_element_name name, unsafe_name, "element name"
check_valid_element_name prefix, unsafe_prefix, "prefix" if prefix
call StartElementNS, unsafe_prefix, unsafe_name, string_to_unsafe(namespace_uri)
end
# Emits the end of an element.
def end_element : Nil
call EndElement
end
# Emits the start of an element with the given *attributes*,
# invokes the block and then emits the end of the element.
def element(__name__ : String, **attributes, &)
element(__name__, attributes) do
yield
end
end
# :ditto:
def element(__name__ : String, attributes : Hash | NamedTuple, &)
start_element __name__
attributes(attributes)
yield.tap { end_element }
end
# Emits an element with the given *attributes*.
def element(__name__ : String, **attributes)
element(__name__, attributes)
end
# :ditto:
def element(name : String, attributes : Hash | NamedTuple) : Nil
element(name, attributes) { }
end
# Emits the start of an element with namespace info with the given *attributes*,
# invokes the block and then emits the end of the element.
def element(__prefix__ : String?, __name__ : String, __namespace_uri__ : String?, **attributes, &)
element(__prefix__, __name__, __namespace_uri__, attributes) do
yield
end
end
# :ditto:
def element(__prefix__ : String?, __name__ : String, __namespace_uri__ : String?, attributes : Hash | NamedTuple, &)
start_element __prefix__, __name__, __namespace_uri__
attributes(attributes)
yield.tap { end_element }
end
# Emits an element with namespace info with the given *attributes*.
def element(prefix : String?, name : String, namespace_uri : String?, **attributes) : Nil
element(prefix, name, namespace_uri, attributes)
end
# :ditto:
def element(prefix : String?, name : String, namespace_uri : String?, attributes : Hash | NamedTuple) : Nil
start_element(prefix, name, namespace_uri)
attributes(attributes)
end_element
end
# Emits the start of an attribute.
def start_attribute(name : String) : Nil
call StartAttribute, string_to_unsafe(name)
end
# Emits the start of an attribute with namespace info.
def start_attribute(prefix : String?, name : String, namespace_uri : String?)
call StartAttributeNS, string_to_unsafe(prefix), string_to_unsafe(name), string_to_unsafe(namespace_uri)
end
# Emits the end of an attribute.
def end_attribute : Nil
call EndAttribute
end
# Emits the start of an attribute, invokes the block,
# and then emits the end of the attribute.
def attribute(*args, **nargs, &)
start_attribute *args, **nargs
yield.tap { end_attribute }
end
# Emits an attribute with a *value*.
def attribute(name : String, value) : Nil
call WriteAttribute, string_to_unsafe(name), string_to_unsafe(value.to_s)
end
# Emits an attribute with namespace info and a *value*.
def attribute(prefix : String?, name : String, namespace_uri : String?, value) : Nil
call WriteAttributeNS, string_to_unsafe(prefix), string_to_unsafe(name), string_to_unsafe(namespace_uri), string_to_unsafe(value.to_s)
end
# Emits the given *attributes* with their values.
def attributes(**attributes)
attributes(attributes)
end
# :ditto:
def attributes(attributes : Hash | NamedTuple) : Nil
attributes.each do |key, value|
attribute key.to_s, value
end
end
# Emits text content.
#
# Text content can happen inside of an `element`, `attribute` value, `cdata`, `dtd`, etc.
def text(content : String) : Nil
call WriteString, string_to_unsafe(content)
end
# Emits the start of a `CDATA` section.
def start_cdata : Nil
call StartCDATA
end
# Emits the end of a `CDATA` section.
def end_cdata : Nil
call EndCDATA
end
# Emits the start of a `CDATA` section, invokes the block
# and then emits the end of the `CDATA` section.
#
# NOTE: `CDATA` end sequences written within the block
# need to be escaped manually.
def cdata(&)
start_cdata
yield.tap { end_cdata }
end
# Emits a `CDATA` section. Escapes nested `CDATA` end sequences.
def cdata(text : String) : Nil
call WriteCDATA, string_to_unsafe(text.gsub(CDATA_END, CDATA_ESCAPE))
end
# Emits the start of a comment.
def start_comment : Nil
call StartComment
end
# Emits the end of a comment.
def end_comment : Nil
call EndComment
end
# Emits the start of a comment, invokes the block
# and then emits the end of the comment.
def comment(&)
start_comment
yield.tap { end_comment }
end
# Emits a comment.
def comment(text : String) : Nil
call WriteComment, string_to_unsafe(text)
end
# Emits the start of a `DTD`.
def start_dtd(name : String, pubid : String, sysid : String) : Nil
call StartDTD, string_to_unsafe(name), string_to_unsafe(pubid), string_to_unsafe(sysid)
end
# Emits the end of a `DTD`.
def end_dtd : Nil
call EndDTD
end
# Emits the start of a `DTD`, invokes the block
# and then emits the end of the `DTD`.
def dtd(name : String, pubid : String, sysid : String, &) : Nil
start_dtd name, pubid, sysid
yield.tap { end_dtd }
end
# Emits a `DTD`.
def dtd(name : String, pubid : String, sysid : String, subset : String? = nil) : Nil
call WriteDTD, string_to_unsafe(name), string_to_unsafe(pubid), string_to_unsafe(sysid), string_to_unsafe(subset)
end
# Emits a namespace.
def namespace(prefix, uri) : Nil
attribute "xmlns", prefix, nil, uri
end
# Forces content written to this writer to be flushed to
# this writer's `IO`.
def flush : Nil
call Flush
@io.flush
end
# Sets the indent string.
def indent=(str : String)
if str.empty?
call SetIndent, 0
else
call SetIndent, 1
call SetIndentString, string_to_unsafe(str)
end
end
# Sets the indent *level* (number of spaces).
def indent=(level : Int)
if level <= 0
call SetIndent, 0
else
call SetIndent, 1
call SetIndentString, " " * level
end
end
# Sets the quote char to use, either `'` or `"`.
def quote_char=(char : Char)
unless char.in?('\'', '"')
raise ArgumentError.new("Quote char must be ' or \", not #{char}")
end
call SetQuoteChar, char.ord
end
private macro call(name, *args)
ret = LibXML.xmlTextWriter{{name}}(@writer, {{args.splat}})
check ret, {{@def.name.stringify}}
end
private def check(ret, msg)
raise XML::Error.new("Error in #{msg}", 0) if ret < 0
end
private def check_valid_element_name(name : String, unsafe_name : Pointer(UInt8), element_type : String) : Nil
raise XML::Error.new("Invalid #{element_type}: '#{name}'", 0) if LibXML.xmlValidateNameValue(unsafe_name).zero?
end
private def string_to_unsafe(string : String)
raise XML::Error.new("String cannot contain null character", 0) if string.includes? '\0'
string.to_unsafe
end
private def string_to_unsafe(string : Nil)
Pointer(UInt8).null
end
end
module XML
# Returns the resulting `String` of writing XML to the yielded `XML::Builder`.
#
# Builds an XML document (see `#document`) including XML declaration (``).
#
# ```
# require "xml"
#
# string = XML.build(indent: " ") do |xml|
# xml.element("person", id: 1) do
# xml.element("firstname") { xml.text "Jane" }
# xml.element("lastname") { xml.text "Doe" }
# end
# end
#
# string # => "\n\n Jane\n Doe\n\n"
# ```
def self.build(version : String? = nil, encoding : String? = nil, indent = nil, quote_char = nil, &)
String.build do |str|
build(str, version, encoding, indent, quote_char) do |xml|
yield xml
end
end
end
# Returns the resulting `String` of writing XML to the yielded `XML::Builder`.
#
# Builds an XML fragment without XML declaration (``).
#
# ```
# require "xml"
#
# string = XML.build_fragment(indent: " ") do |xml|
# xml.element("person", id: 1) do
# xml.element("firstname") { xml.text "Jane" }
# xml.element("lastname") { xml.text "Doe" }
# end
# end
#
# string # => "\n Jane\n Doe\n\n"
# ```
def self.build_fragment(*, indent = nil, quote_char = nil, &)
String.build do |str|
build_fragment(str, indent: indent, quote_char: quote_char) do |xml|
yield xml
end
end
end
# Writes XML document into the given `IO`. An `XML::Builder` is yielded to the block.
#
# Builds an XML document (see `#document`) including XML declaration (``).
def self.build(io : IO, version : String? = nil, encoding : String? = nil, indent = nil, quote_char = nil, &) : Nil
build_fragment(io, indent: indent, quote_char: quote_char) do |xml|
xml.start_document version, encoding
yield xml
# omit end_document because it is called in build_fragment
end
end
# Writes XML fragment into the given `IO`. An `XML::Builder` is yielded to the block.
#
# Builds an XML fragment without XML declaration (``).
def self.build_fragment(io : IO, *, indent = nil, quote_char = nil, &) : Nil
xml = XML::Builder.new(io)
xml.indent = indent if indent
xml.quote_char = quote_char if quote_char
v = yield xml
# EndDocument is still necessary to ensure all elements are closed, even
# when StartDocument is omitted.
xml.end_document
xml.flush
v
end
end