philiprehberger-xml_builder 0.2.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82e57a50119722850b37aea9fb4dc8d4ed53774c8377750baee25faf0b118b76
4
- data.tar.gz: 41d22968c8eff5a8e31c1223f6169d40aa65ac5b2c9eb07034495681a0dcd94f
3
+ metadata.gz: 720c31767f0706544320e00c6fb4ed6e3dd946f601c68c947c6eaf409c324dd2
4
+ data.tar.gz: a8dafaef9908d5f485cdf57fc65654a8b11610e93575a27f78b668f6b9e342e4
5
5
  SHA512:
6
- metadata.gz: 7923d521f4ad86f86d608541cb482b4cadc1a41a378d4a228a4e9e48dc9068047dadfb4670d3e3a2060bac706f2df02f1c426ea48a1421bb4570eb3c52524c05
7
- data.tar.gz: 53aa3ea970baf6adf08387b2a9900f61390ea8ec316d9de912205bab2095f2d3c90a3fb6ffa0affb847e5598ff2d60ce73dcb998a99cb2a4dd1da17763f69fac
6
+ metadata.gz: 466443cf83706000e5ae81f7a8fd5cf673cb7aad9306a4de70d189182af0459e75a415b047063af46feaffe227342f3c1b51ac8288e312cca5ba2983efd38233
7
+ data.tar.gz: 5c904af4d1da2f0899771115ac35632ebd0c871c65236e24a98001522821a23e93d003723c4b4b98470da8579d8267a52443ba91243e16e46571a76fd6164515
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-04-16
11
+
12
+ ### Added
13
+ - Processing instruction (PI) node support via `Document#processing_instruction` / `#pi`
14
+
15
+ ## [0.3.0] - 2026-04-10
16
+
17
+ ### Added
18
+ - Add `declaration:` option to `build`, `build_soap`, and `Document.new` to omit the XML declaration when building fragments
19
+ - Add CDATA content validation — raises `Error` if content contains `]]>`
20
+ - Add comment text validation — raises `Error` if text contains `--`
21
+ - Add Ruby 3.4 to CI test matrix
22
+
23
+ ## [0.2.2] - 2026-03-31
24
+
25
+ ### Added
26
+ - Add GitHub issue templates, dependabot config, and PR template
27
+
10
28
  ## [0.2.1] - 2026-03-31
11
29
 
12
30
  ### Changed
data/README.md CHANGED
@@ -68,6 +68,20 @@ end
68
68
 
69
69
  ### Processing Instructions
70
70
 
71
+ Pass keyword attributes to emit a structured PI with XML-escaped values:
72
+
73
+ ```ruby
74
+ xml = Philiprehberger::XmlBuilder.build do |doc|
75
+ doc.pi("xml-stylesheet", href: "style.xsl", type: "text/xsl")
76
+ doc.tag(:root) { doc.text("content") }
77
+ end
78
+
79
+ puts xml
80
+ # <?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="style.xsl" type="text/xsl"?><root>content</root>
81
+ ```
82
+
83
+ A legacy positional content string is also supported:
84
+
71
85
  ```ruby
72
86
  xml = Philiprehberger::XmlBuilder.build do |doc|
73
87
  doc.processing_instruction("xml-stylesheet", 'type="text/xsl" href="style.xsl"')
@@ -98,6 +112,19 @@ xml = Philiprehberger::XmlBuilder.build do |doc|
98
112
  end
99
113
  ```
100
114
 
115
+ ### Without XML Declaration
116
+
117
+ Omit the `<?xml ... ?>` declaration when building fragments:
118
+
119
+ ```ruby
120
+ xml = Philiprehberger::XmlBuilder.build(declaration: false) do |doc|
121
+ doc.tag(:item, id: "1") { doc.text("fragment") }
122
+ end
123
+
124
+ puts xml
125
+ # <item id="1">fragment</item>
126
+ ```
127
+
101
128
  ### XML Namespaces
102
129
 
103
130
  Register namespace prefixes and create namespace-aware elements:
@@ -184,8 +211,8 @@ end
184
211
 
185
212
  | Method | Description |
186
213
  |--------|-------------|
187
- | `.build(encoding: "UTF-8", version: "1.0") { \|doc\| ... }` | Build an XML document and return the string |
188
- | `.build_soap(soap_version: "1.1", encoding: "UTF-8", version: "1.0") { \|header, body\| ... }` | Build a SOAP envelope document |
214
+ | `.build(encoding: "UTF-8", version: "1.0", declaration: true) { \|doc\| ... }` | Build an XML document and return the string |
215
+ | `.build_soap(soap_version: "1.1", encoding: "UTF-8", version: "1.0", declaration: true) { \|header, body\| ... }` | Build a SOAP envelope document |
189
216
 
190
217
  ### `Document`
191
218
 
@@ -196,6 +223,7 @@ end
196
223
  | `#cdata(content)` | Add a CDATA section |
197
224
  | `#comment(text)` | Add an XML comment |
198
225
  | `#processing_instruction(target, content)` | Add a processing instruction |
226
+ | `#processing_instruction(target, **attrs)` | Append an XML processing instruction (alias: `#pi`) |
199
227
  | `#raw(string)` | Add raw unescaped XML |
200
228
  | `#namespace(prefix, uri)` | Register an XML namespace prefix and URI |
201
229
  | `#namespace_tag(prefix, name, attributes = {}) { ... }` | Add a namespace-prefixed element with auto xmlns |
@@ -10,9 +10,10 @@ module Philiprehberger
10
10
 
11
11
  # @param version [String] XML version for the declaration
12
12
  # @param encoding [String] XML encoding for the declaration
13
- def initialize(version: '1.0', encoding: 'UTF-8')
13
+ def initialize(version: '1.0', encoding: 'UTF-8', declaration: true)
14
14
  @version = version
15
15
  @encoding = encoding
16
+ @declaration = declaration
16
17
  @children = []
17
18
  @node_stack = []
18
19
  @namespaces = {}
@@ -50,6 +51,8 @@ module Philiprehberger
50
51
  # @param content [String] the CDATA content (must not contain "]]>")
51
52
  # @return [void]
52
53
  def cdata(content)
54
+ raise Error, 'CDATA content must not contain "]]>"' if content.to_s.include?(']]>')
55
+
53
56
  current_parent.push("<![CDATA[#{content}]]>")
54
57
  end
55
58
 
@@ -58,18 +61,38 @@ module Philiprehberger
58
61
  # @param text [String] the comment text
59
62
  # @return [void]
60
63
  def comment(text)
64
+ raise Error, 'Comment text must not contain "--"' if text.to_s.include?('--')
65
+
61
66
  current_parent.push("<!-- #{text} -->")
62
67
  end
63
68
 
69
+ # Valid PI target pattern per the XML spec (simplified).
70
+ PI_TARGET_PATTERN = /\A[A-Za-z_][\w.-]*\z/
71
+
64
72
  # Add a processing instruction.
65
73
  #
66
- # @param target [String] the PI target
67
- # @param content [String] the PI content
74
+ # Accepts either a legacy positional content string or keyword attributes.
75
+ # When attrs are given, renders as <?target key="value" key2="value2"?>.
76
+ # When a content string is given, renders as <?target content?>.
77
+ #
78
+ # @param target [String] the PI target (must match PI_TARGET_PATTERN; "xml" is forbidden)
79
+ # @param content [String, nil] optional raw PI content string (legacy)
80
+ # @param attrs [Hash] attribute key/value pairs (XML-escaped)
68
81
  # @return [void]
69
- def processing_instruction(target, content)
70
- current_parent.push("<?#{target} #{content}?>")
82
+ # @raise [ArgumentError] if target is empty, invalid, or equal to "xml" (case-insensitive)
83
+ def processing_instruction(target, content = nil, **attrs)
84
+ validate_pi_target!(target)
85
+
86
+ if content.is_a?(String)
87
+ current_parent.push("<?#{target} #{content}?>")
88
+ else
89
+ current_parent.push(ProcessingInstruction.new(target, attrs))
90
+ end
71
91
  end
72
92
 
93
+ # Alias for #processing_instruction.
94
+ alias pi processing_instruction
95
+
73
96
  # Add raw XML content without escaping.
74
97
  #
75
98
  # @param string [String] raw XML string
@@ -90,8 +113,12 @@ module Philiprehberger
90
113
  # @param indent [Integer, nil] number of spaces per indentation level, or nil for compact output
91
114
  # @return [String] the rendered XML document
92
115
  def to_xml(indent: nil)
93
- parts = ["<?xml version=\"#{@version}\" encoding=\"#{@encoding}\"?>"]
94
- parts << (indent ? "\n" : '')
116
+ parts = []
117
+
118
+ if @declaration
119
+ parts << "<?xml version=\"#{@version}\" encoding=\"#{@encoding}\"?>"
120
+ parts << (indent ? "\n" : '')
121
+ end
95
122
 
96
123
  @children.each do |child|
97
124
  parts << render_child(child, indent: indent, level: 0)
@@ -227,10 +254,18 @@ module Philiprehberger
227
254
  @node_stack.last&.children || @children
228
255
  end
229
256
 
257
+ def validate_pi_target!(target)
258
+ raise ArgumentError, 'PI target must be a non-empty String' unless target.is_a?(String) && !target.empty?
259
+ raise ArgumentError, 'PI target "xml" is reserved by the XML specification' if target.casecmp('xml').zero?
260
+ raise ArgumentError, "Invalid PI target: #{target.inspect}" unless target.match?(PI_TARGET_PATTERN)
261
+ end
262
+
230
263
  def render_child(child, indent:, level:)
231
264
  case child
232
265
  when Node
233
266
  child.render(indent: indent, level: level)
267
+ when ProcessingInstruction
268
+ child.to_xml(indent: indent, level: level, pretty: !indent.nil?)
234
269
  when String
235
270
  if indent
236
271
  "#{' ' * (indent * level)}#{child}\n"
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Philiprehberger
4
+ module XmlBuilder
5
+ # Represents an XML processing instruction (PI).
6
+ #
7
+ # Renders as <?target key="value" key2="value2"?>, with attribute
8
+ # values escaped via Escaper.
9
+ class ProcessingInstruction
10
+ attr_reader :target, :attributes
11
+
12
+ # @param target [String] the PI target name
13
+ # @param attributes [Hash] attribute key/value pairs
14
+ def initialize(target, attributes = {})
15
+ @target = target.to_s
16
+ @attributes = attributes
17
+ end
18
+
19
+ # Render this processing instruction as an XML string.
20
+ #
21
+ # @param indent [Integer, nil] number of spaces per indentation level, or nil for compact output
22
+ # @param level [Integer] current nesting depth (used internally)
23
+ # @param pretty [Boolean] whether to apply pretty-print formatting
24
+ # @return [String] the rendered processing instruction
25
+ def to_xml(indent: nil, level: 0, pretty: false)
26
+ prefix = indent && pretty ? ' ' * (indent * level) : ''
27
+ newline = indent && pretty ? "\n" : ''
28
+ "#{prefix}<?#{@target}#{render_attributes}?>#{newline}"
29
+ end
30
+
31
+ # Alias for to_xml to match Node#render semantics.
32
+ def render(indent: nil, level: 0)
33
+ to_xml(indent: indent, level: level, pretty: !indent.nil?)
34
+ end
35
+
36
+ private
37
+
38
+ def render_attributes
39
+ return '' if @attributes.empty?
40
+
41
+ pairs = @attributes.map do |key, value|
42
+ attr_name = key.to_s.gsub('__', ':')
43
+ " #{attr_name}=\"#{Escaper.escape(value)}\""
44
+ end
45
+ pairs.join
46
+ end
47
+ end
48
+ end
49
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module XmlBuilder
5
- VERSION = '0.2.1'
5
+ VERSION = '0.4.0'
6
6
  end
7
7
  end
@@ -3,6 +3,7 @@
3
3
  require_relative 'xml_builder/version'
4
4
  require_relative 'xml_builder/escaper'
5
5
  require_relative 'xml_builder/node'
6
+ require_relative 'xml_builder/processing_instruction'
6
7
  require_relative 'xml_builder/document'
7
8
 
8
9
  module Philiprehberger
@@ -15,8 +16,8 @@ module Philiprehberger
15
16
  # @param version [String] XML version declaration (default: "1.0")
16
17
  # @yield [Document] the document builder
17
18
  # @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)
19
+ def self.build(encoding: 'UTF-8', version: '1.0', declaration: true, &block)
20
+ doc = Document.new(version: version, encoding: encoding, declaration: declaration)
20
21
  block.call(doc)
21
22
  doc.to_s
22
23
  end
@@ -31,8 +32,8 @@ module Philiprehberger
31
32
  # @param version [String] XML version declaration (default: "1.0")
32
33
  # @yield [header, body] yields two arrays; push lambdas that accept a doc
33
34
  # @return [String] the rendered SOAP XML string
34
- def self.build_soap(soap_version: '1.1', encoding: 'UTF-8', version: '1.0', &block)
35
- doc = Document.new(version: version, encoding: encoding)
35
+ def self.build_soap(soap_version: '1.1', encoding: 'UTF-8', version: '1.0', declaration: true, &block)
36
+ doc = Document.new(version: version, encoding: encoding, declaration: declaration)
36
37
  doc.soap_envelope(version: soap_version, &block)
37
38
  doc.to_s
38
39
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: philiprehberger-xml_builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Philip Rehberger
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-31 00:00:00.000000000 Z
11
+ date: 2026-04-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Programmatic XML construction with a clean DSL, auto-escaping, CDATA,
14
14
  comments, processing instructions, and pretty printing. Zero dependencies.
@@ -25,12 +25,13 @@ files:
25
25
  - lib/philiprehberger/xml_builder/document.rb
26
26
  - lib/philiprehberger/xml_builder/escaper.rb
27
27
  - lib/philiprehberger/xml_builder/node.rb
28
+ - lib/philiprehberger/xml_builder/processing_instruction.rb
28
29
  - lib/philiprehberger/xml_builder/version.rb
29
- homepage: https://github.com/philiprehberger/rb-xml-builder
30
+ homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-xml_builder
30
31
  licenses:
31
32
  - MIT
32
33
  metadata:
33
- homepage_uri: https://github.com/philiprehberger/rb-xml-builder
34
+ homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-xml_builder
34
35
  source_code_uri: https://github.com/philiprehberger/rb-xml-builder
35
36
  changelog_uri: https://github.com/philiprehberger/rb-xml-builder/blob/main/CHANGELOG.md
36
37
  bug_tracker_uri: https://github.com/philiprehberger/rb-xml-builder/issues