philiprehberger-xml_builder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 922733a983681c827ed0fc8e94454e002abf37679785f1daba0153b9fff1dc01
4
+ data.tar.gz: 6826f30ce35d4c783862d55aee71819b643a0c41e1636d30d774afaba8928e37
5
+ SHA512:
6
+ metadata.gz: 2a1c52baeb6dd26956f6d6b0662742cc61872bc1a27cd45e3512bc99bd0db1486e30953cecf7661540dd66097b68e9cd1083b75d33d5aa90f0bbd34ea658ba19
7
+ data.tar.gz: 4eb32107f4e5007bb7f6afea092cfbd25f927fc73bd4251d81384ae97d46d7931e858295ca0f7970d8c28111d84f55bc697448069ec03dba7ffe32ef1711e627
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this gem will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-03-26
11
+
12
+ ### Added
13
+ - Initial release
14
+ - Block-based DSL for building XML documents
15
+ - XML declaration with configurable version and encoding
16
+ - Element creation with attributes via `tag` method
17
+ - Escaped text content via `text` method
18
+ - CDATA sections via `cdata` method
19
+ - XML comments via `comment` method
20
+ - Processing instructions via `processing_instruction` method
21
+ - Raw XML insertion via `raw` method
22
+ - method_missing DSL for natural element creation
23
+ - Pretty printing with configurable indentation via `to_xml(indent:)`
24
+ - XML entity escaping for all five standard entities (&, <, >, ", ')
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 philiprehberger
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do 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.
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # philiprehberger-xml_builder
2
+
3
+ [![Tests](https://github.com/philiprehberger/rb-xml-builder/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-xml-builder/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/philiprehberger-xml_builder.svg)](https://rubygems.org/gems/philiprehberger-xml_builder)
5
+ [![License](https://img.shields.io/github/license/philiprehberger/rb-xml-builder)](LICENSE)
6
+ [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
7
+
8
+ Lightweight XML builder DSL without Nokogiri dependency
9
+
10
+ ## Requirements
11
+
12
+ - Ruby >= 3.1
13
+
14
+ ## Installation
15
+
16
+ Add to your Gemfile:
17
+
18
+ ```ruby
19
+ gem "philiprehberger-xml_builder"
20
+ ```
21
+
22
+ Or install directly:
23
+
24
+ ```bash
25
+ gem install philiprehberger-xml_builder
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```ruby
31
+ require "philiprehberger/xml_builder"
32
+
33
+ xml = Philiprehberger::XmlBuilder.build do |doc|
34
+ doc.tag(:root) do
35
+ doc.tag(:item, id: "1") { doc.text("Hello") }
36
+ end
37
+ end
38
+
39
+ puts xml
40
+ # <?xml version="1.0" encoding="UTF-8"?><root><item id="1">Hello</item></root>
41
+ ```
42
+
43
+ ### Method Missing DSL
44
+
45
+ Use method names directly as tag names for a cleaner syntax:
46
+
47
+ ```ruby
48
+ xml = Philiprehberger::XmlBuilder.build do |doc|
49
+ doc.person(name: "John") do
50
+ doc.age("30")
51
+ doc.email("john@example.com")
52
+ end
53
+ end
54
+ # <person name="John"><age>30</age><email>john@example.com</email></person>
55
+ ```
56
+
57
+ ### CDATA and Comments
58
+
59
+ ```ruby
60
+ xml = Philiprehberger::XmlBuilder.build do |doc|
61
+ doc.tag(:root) do
62
+ doc.comment("Generated XML")
63
+ doc.tag(:script) { doc.cdata('var x = 1 < 2;') }
64
+ end
65
+ end
66
+ ```
67
+
68
+ ### Processing Instructions
69
+
70
+ ```ruby
71
+ xml = Philiprehberger::XmlBuilder.build do |doc|
72
+ doc.processing_instruction("xml-stylesheet", 'type="text/xsl" href="style.xsl"')
73
+ doc.tag(:root) { doc.text("content") }
74
+ end
75
+ ```
76
+
77
+ ### Pretty Printing
78
+
79
+ ```ruby
80
+ doc = Philiprehberger::XmlBuilder::Document.new
81
+ doc.tag(:root) do
82
+ doc.tag(:child) { doc.text("value") }
83
+ end
84
+
85
+ puts doc.to_xml(indent: 2)
86
+ # <?xml version="1.0" encoding="UTF-8"?>
87
+ # <root>
88
+ # <child>value</child>
89
+ # </root>
90
+ ```
91
+
92
+ ### Raw XML
93
+
94
+ ```ruby
95
+ xml = Philiprehberger::XmlBuilder.build do |doc|
96
+ doc.tag(:root) { doc.raw("<pre>formatted</pre>") }
97
+ end
98
+ ```
99
+
100
+ ## API
101
+
102
+ ### `Philiprehberger::XmlBuilder`
103
+
104
+ | Method | Description |
105
+ |--------|-------------|
106
+ | `.build(encoding: "UTF-8", version: "1.0") { \|doc\| ... }` | Build an XML document and return the string |
107
+
108
+ ### `Document`
109
+
110
+ | Method | Description |
111
+ |--------|-------------|
112
+ | `#tag(name, attributes = {}) { ... }` | Add an element with optional attributes and children |
113
+ | `#text(content)` | Add escaped text content |
114
+ | `#cdata(content)` | Add a CDATA section |
115
+ | `#comment(text)` | Add an XML comment |
116
+ | `#processing_instruction(target, content)` | Add a processing instruction |
117
+ | `#raw(string)` | Add raw unescaped XML |
118
+ | `#to_s` | Render compact XML string |
119
+ | `#to_xml(indent: nil)` | Render XML with optional indentation |
120
+
121
+ ### `Escaper`
122
+
123
+ | Method | Description |
124
+ |--------|-------------|
125
+ | `.escape(text)` | Escape XML entities (&, <, >, ", ') |
126
+
127
+ ## Development
128
+
129
+ ```bash
130
+ bundle install
131
+ bundle exec rspec
132
+ bundle exec rubocop
133
+ ```
134
+
135
+ ## License
136
+
137
+ [MIT](LICENSE)
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module XmlBuilder
5
+ # Accumulates XML nodes and renders the final document.
6
+ #
7
+ # Used as the context object inside XmlBuilder.build blocks.
8
+ class Document
9
+ attr_reader :version, :encoding
10
+
11
+ # @param version [String] XML version for the declaration
12
+ # @param encoding [String] XML encoding for the declaration
13
+ def initialize(version: '1.0', encoding: 'UTF-8')
14
+ @version = version
15
+ @encoding = encoding
16
+ @children = []
17
+ @node_stack = []
18
+ end
19
+
20
+ # Add an XML element with optional attributes and nested children.
21
+ #
22
+ # @param name [String, Symbol] the element tag name
23
+ # @param attributes [Hash] element attributes
24
+ # @yield optional block for adding child elements
25
+ # @return [Node] the created node
26
+ def tag(name, attributes = {}, &block)
27
+ node = Node.new(name, attributes)
28
+
29
+ if block
30
+ @node_stack.push(node)
31
+ block.call
32
+ @node_stack.pop
33
+ end
34
+
35
+ current_parent.push(node)
36
+ node
37
+ end
38
+
39
+ # Add escaped text content to the current element.
40
+ #
41
+ # @param content [String] the text content to escape and add
42
+ # @return [void]
43
+ def text(content)
44
+ current_parent.push(Escaper.escape(content.to_s))
45
+ end
46
+
47
+ # Add a CDATA section.
48
+ #
49
+ # @param content [String] the CDATA content (must not contain "]]>")
50
+ # @return [void]
51
+ def cdata(content)
52
+ current_parent.push("<![CDATA[#{content}]]>")
53
+ end
54
+
55
+ # Add an XML comment.
56
+ #
57
+ # @param text [String] the comment text
58
+ # @return [void]
59
+ def comment(text)
60
+ current_parent.push("<!-- #{text} -->")
61
+ end
62
+
63
+ # Add a processing instruction.
64
+ #
65
+ # @param target [String] the PI target
66
+ # @param content [String] the PI content
67
+ # @return [void]
68
+ def processing_instruction(target, content)
69
+ current_parent.push("<?#{target} #{content}?>")
70
+ end
71
+
72
+ # Add raw XML content without escaping.
73
+ #
74
+ # @param string [String] raw XML string
75
+ # @return [void]
76
+ def raw(string)
77
+ current_parent.push(string.to_s)
78
+ end
79
+
80
+ # Render the document as a compact XML string (no indentation).
81
+ #
82
+ # @return [String] the rendered XML document
83
+ def to_s
84
+ to_xml
85
+ end
86
+
87
+ # Render the document as an XML string with optional indentation.
88
+ #
89
+ # @param indent [Integer, nil] number of spaces per indentation level, or nil for compact output
90
+ # @return [String] the rendered XML document
91
+ def to_xml(indent: nil)
92
+ parts = ["<?xml version=\"#{@version}\" encoding=\"#{@encoding}\"?>"]
93
+ parts << (indent ? "\n" : '')
94
+
95
+ @children.each do |child|
96
+ parts << render_child(child, indent: indent, level: 0)
97
+ end
98
+
99
+ parts.join
100
+ end
101
+
102
+ # Support method_missing for DSL-style tag creation.
103
+ #
104
+ # @example
105
+ # xml.person(name: "John") { xml.age("30") }
106
+ # # => <person name="John"><age>30</age></person>
107
+ def method_missing(method_name, *args, &block)
108
+ first_arg = args.first
109
+ attributes = {}
110
+ text_content = nil
111
+
112
+ if first_arg.is_a?(Hash)
113
+ attributes = first_arg
114
+ elsif first_arg
115
+ text_content = first_arg.to_s
116
+ attributes = args[1] if args[1].is_a?(Hash)
117
+ end
118
+
119
+ if text_content
120
+ tag(method_name, attributes) { text(text_content) }
121
+ elsif block
122
+ tag(method_name, attributes, &block)
123
+ else
124
+ tag(method_name, attributes)
125
+ end
126
+ end
127
+
128
+ # @return [Boolean]
129
+ def respond_to_missing?(_method_name, _include_private = false)
130
+ true
131
+ end
132
+
133
+ private
134
+
135
+ def current_parent
136
+ @node_stack.last&.children || @children
137
+ end
138
+
139
+ def render_child(child, indent:, level:)
140
+ case child
141
+ when Node
142
+ child.render(indent: indent, level: level)
143
+ when String
144
+ if indent
145
+ "#{' ' * (indent * level)}#{child}\n"
146
+ else
147
+ child
148
+ end
149
+ else
150
+ child.to_s
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module XmlBuilder
5
+ # XML entity escaping for text content and attribute values.
6
+ module Escaper
7
+ ENTITIES = {
8
+ '&' => '&amp;',
9
+ '<' => '&lt;',
10
+ '>' => '&gt;',
11
+ '"' => '&quot;',
12
+ "'" => '&apos;'
13
+ }.freeze
14
+
15
+ ENTITY_PATTERN = Regexp.union(ENTITIES.keys).freeze
16
+
17
+ # Escape special XML characters in a string.
18
+ #
19
+ # @param text [String] the text to escape
20
+ # @return [String] the escaped text
21
+ def self.escape(text)
22
+ text.to_s.gsub(ENTITY_PATTERN, ENTITIES)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module XmlBuilder
5
+ # Represents a single XML element with optional attributes and children.
6
+ class Node
7
+ attr_reader :name, :attributes, :children
8
+
9
+ # @param name [String, Symbol] the element tag name
10
+ # @param attributes [Hash] element attributes
11
+ def initialize(name, attributes = {})
12
+ @name = name.to_s
13
+ @attributes = attributes
14
+ @children = []
15
+ end
16
+
17
+ # Render this node and its children as an XML string.
18
+ #
19
+ # @param indent [Integer, nil] number of spaces per indentation level, or nil for compact output
20
+ # @param level [Integer] current nesting depth (used internally)
21
+ # @return [String] the rendered XML
22
+ def render(indent: nil, level: 0)
23
+ prefix = indent ? ' ' * (indent * level) : ''
24
+ newline = indent ? "\n" : ''
25
+
26
+ attrs = render_attributes
27
+ tag_open = "#{prefix}<#{@name}#{attrs}"
28
+
29
+ if @children.empty?
30
+ "#{tag_open} />#{newline}"
31
+ else
32
+ parts = ["#{tag_open}>"]
33
+ inline = !indent || @children.all?(String)
34
+
35
+ if inline
36
+ @children.each { |child| parts << render_child(child, indent: nil, level: 0) }
37
+ parts << "</#{@name}>#{newline}"
38
+ else
39
+ parts[0] << newline
40
+ @children.each { |child| parts << render_child(child, indent: indent, level: level + 1) }
41
+ parts << "#{prefix}</#{@name}>#{newline}"
42
+ end
43
+ parts.join
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def render_attributes
50
+ return '' if @attributes.empty?
51
+
52
+ pairs = @attributes.map do |key, value|
53
+ " #{key}=\"#{Escaper.escape(value)}\""
54
+ end
55
+ pairs.join
56
+ end
57
+
58
+ def render_child(child, indent:, level:)
59
+ case child
60
+ when Node
61
+ child.render(indent: indent, level: level)
62
+ when String
63
+ if indent && level.positive?
64
+ "#{' ' * (indent * level)}#{child}\n"
65
+ else
66
+ child
67
+ end
68
+ else
69
+ child.to_s
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module XmlBuilder
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'xml_builder/version'
4
+ require_relative 'xml_builder/escaper'
5
+ require_relative 'xml_builder/node'
6
+ require_relative 'xml_builder/document'
7
+
8
+ module Philiprehberger
9
+ module XmlBuilder
10
+ class Error < StandardError; end
11
+
12
+ # Build an XML document using a block-based DSL.
13
+ #
14
+ # @param encoding [String] XML encoding declaration (default: "UTF-8")
15
+ # @param version [String] XML version declaration (default: "1.0")
16
+ # @yield [Document] the document builder
17
+ # @return [String] the rendered XML string
18
+ def self.build(encoding: 'UTF-8', version: '1.0', &block)
19
+ doc = Document.new(version: version, encoding: encoding)
20
+ block.call(doc)
21
+ doc.to_s
22
+ end
23
+ end
24
+ end
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: philiprehberger-xml_builder
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Philip Rehberger
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-03-27 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Programmatic XML construction with a clean DSL, auto-escaping, CDATA,
14
+ comments, processing instructions, and pretty printing. Zero dependencies.
15
+ email:
16
+ - me@philiprehberger.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - lib/philiprehberger/xml_builder.rb
25
+ - lib/philiprehberger/xml_builder/document.rb
26
+ - lib/philiprehberger/xml_builder/escaper.rb
27
+ - lib/philiprehberger/xml_builder/node.rb
28
+ - lib/philiprehberger/xml_builder/version.rb
29
+ homepage: https://github.com/philiprehberger/rb-xml-builder
30
+ licenses:
31
+ - MIT
32
+ metadata:
33
+ homepage_uri: https://github.com/philiprehberger/rb-xml-builder
34
+ source_code_uri: https://github.com/philiprehberger/rb-xml-builder
35
+ changelog_uri: https://github.com/philiprehberger/rb-xml-builder/blob/main/CHANGELOG.md
36
+ bug_tracker_uri: https://github.com/philiprehberger/rb-xml-builder/issues
37
+ rubygems_mfa_required: 'true'
38
+ post_install_message:
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 3.1.0
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 3.5.22
54
+ signing_key:
55
+ specification_version: 4
56
+ summary: Lightweight XML builder DSL without Nokogiri dependency
57
+ test_files: []