Class: Docbook::XIncludeResolver

Inherits:
Object
  • Object
show all
Defined in:
lib/docbook/xinclude_resolver.rb

Constant Summary collapse

XINCLUDE_NS =
"http://www.w3.org/2001/XInclude"

Class Method Summary collapse

Class Method Details

.apply_char_fragid(content, char_spec) ⇒ Object

Apply char= fragid scheme (RFC 5147) Syntax: char=START,END



215
216
217
218
219
220
# File 'lib/docbook/xinclude_resolver.rb', line 215

def self.apply_char_fragid(content, char_spec)
  char_start, char_end = char_spec.split(",").map(&:to_i)
  return nil unless char_start && char_end

  content[char_start...char_end]
end

.apply_delimited_search(lines, spec, delim) ⇒ Object

Parse delimited patterns and apply search Delimiter is # (literal) or / (regex)



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/docbook/xinclude_resolver.rb', line 124

def self.apply_delimited_search(lines, spec, delim)
  # Parse: DELIM pattern DELIM [;modifier] [, DELIM stop DELIM]
  remainder = spec[1..] # skip opening delimiter
  close_idx = remainder.index(delim)
  return nil unless close_idx

  pattern_str = remainder[0...close_idx]
  remainder = remainder[(close_idx + 1)..]

  # Check for modifier (;after or ;before)
  modifier = nil
  if remainder&.start_with?(";")
    mod_match = remainder.match(/\A;(after|before)/)
    if mod_match
      modifier = mod_match[1].to_sym
      remainder = remainder[mod_match[0].length..]
    end
  end

  # Skip comma separator
  remainder = remainder[1..] if remainder&.start_with?(",")

  # Parse stop pattern if present
  stop_pattern = nil
  if remainder && !remainder.empty? && remainder.start_with?(delim)
    stop_remainder = remainder[1..]
    stop_close = stop_remainder.index(delim)
    stop_pattern = stop_remainder[0...stop_close] if stop_close
  end

  # Apply search
  match_fn = if delim == "/"
               ->(line, pat) { line.match?(Regexp.new(pat)) }
             else
               ->(line, pat) { line.include?(pat) }
             end

  start_idx = lines.index { |l| match_fn.call(l, pattern_str) }
  return nil unless start_idx

  # Determine range
  case modifier
  when :after
    range_start = start_idx + 1
  when :before
    range_start = 0
    range_end = start_idx
  else
    range_start = start_idx
  end

  # Find stop (exclusive)
  if stop_pattern
    stop_slice = lines[range_start..]
    stop_idx = stop_slice&.index { |l| match_fn.call(l, stop_pattern) }
    range_end = stop_idx ? range_start + stop_idx : lines.length
  elsif modifier != :before
    range_end = start_idx + 1
  end

  range_end ||= lines.length
  selected = lines[range_start...range_end]
  return nil unless selected && !selected.empty?

  selected.join
end

.apply_fragid(content, fragid) ⇒ Object

Apply a fragid filter to text content



100
101
102
103
104
105
106
107
108
# File 'lib/docbook/xinclude_resolver.rb', line 100

def self.apply_fragid(content, fragid)
  if fragid.start_with?("search=")
    apply_search_fragid(content, fragid[7..])
  elsif fragid.start_with?("line=")
    apply_line_fragid(content, fragid[5..])
  elsif fragid.start_with?("char=")
    apply_char_fragid(content, fragid[5..])
  end
end

.apply_line_fragid(content, line_spec) ⇒ Object

Apply line= fragid scheme (RFC 5147) Syntax: line=START,END[;length=N]



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# File 'lib/docbook/xinclude_resolver.rb', line 193

def self.apply_line_fragid(content, line_spec)
  parts = line_spec.split(";")
  range_part = parts[0]
  length_part = parts.find { |p| p.start_with?("length=") }

  line_start, line_end = range_part.split(",").map(&:to_i)
  return nil unless line_start && line_end

  lines = content.lines
  selected = lines[(line_start - 1)...(line_end)]
  return nil unless selected

  result = selected.join
  if length_part
    max_len = length_part.split("=")[1].to_i
    result = result[0...max_len] if max_len.positive?
  end
  result
end

.apply_search_fragid(content, search_spec) ⇒ Object

Apply search= fragid scheme (DocBook xslTNG extension) Syntax: search=#PATTERN#[;after|;before],#STOP# or: search=/REGEX/[,/STOP/]



113
114
115
116
117
118
119
120
# File 'lib/docbook/xinclude_resolver.rb', line 113

def self.apply_search_fragid(content, search_spec)
  lines = content.lines
  if search_spec.start_with?("/")
    apply_delimited_search(lines, search_spec, "/")
  elsif search_spec.start_with?("#")
    apply_delimited_search(lines, search_spec, "#")
  end
end

.base_dir_for(document) ⇒ Object



84
85
86
87
88
89
# File 'lib/docbook/xinclude_resolver.rb', line 84

def self.base_dir_for(document)
  url = document.url
  return nil unless url

  File.dirname(url.sub(%r{^file://}, ""))
end

.resolve(doc) ⇒ Object

Resolve XInclude elements in a Nokogiri document. Handles standard XML includes, text includes, and text+fragid extensions. Processes iteratively to handle nested includes (e.g., file A includes file B which contains text+fragid self-references).



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# File 'lib/docbook/xinclude_resolver.rb', line 13

def self.resolve(doc)
  loop do
    includes = doc.xpath("//xi:include", "xi" => XINCLUDE_NS)
    break if includes.empty?

    resolved_any = false
    includes.each do |inc|
      href = inc["href"]
      next unless href

      parse_mode = inc["parse"] || "xml"
      fragid = inc["fragid"]
      base_dir = base_dir_for(inc.document)
      file_path = resolve_path(base_dir, href)
      next unless file_path && File.exist?(file_path)

      if parse_mode == "text"
        resolve_text_include(doc, inc, file_path, fragid)
      else
        resolve_xml_include(doc, inc, file_path)
      end
      resolved_any = true
      break # re-scan after each resolve (tree changes)
    end
    break unless resolved_any
  end
  doc
rescue StandardError
  doc
end

.resolve_path(base_dir, href) ⇒ Object



91
92
93
94
95
# File 'lib/docbook/xinclude_resolver.rb', line 91

def self.resolve_path(base_dir, href)
  return nil unless base_dir

  File.join(base_dir, href)
end

.resolve_string(xml_string, base_path: nil) ⇒ Object

Pre-process an XML string to resolve XIncludes before model parsing.



45
46
47
48
49
50
51
52
53
# File 'lib/docbook/xinclude_resolver.rb', line 45

def self.resolve_string(xml_string, base_path: nil)
  doc = if base_path
          file_uri = "file://#{File.expand_path(base_path)}"
          Nokogiri::XML(xml_string, file_uri)
        else
          Nokogiri::XML(xml_string)
        end
  resolve(doc)
end

.resolve_text_include(doc, inc, file_path, fragid) ⇒ Object

── Include Resolution ──────────────────────────────────────────



57
58
59
60
61
62
63
64
65
# File 'lib/docbook/xinclude_resolver.rb', line 57

def self.resolve_text_include(doc, inc, file_path, fragid)
  content = File.read(file_path)
  if fragid && !fragid.empty?
    filtered = apply_fragid(content, fragid)
    content = filtered if filtered
  end
  text_node = doc.create_text_node(content)
  inc.replace(text_node)
end

.resolve_xml_include(doc, inc, file_path) ⇒ Object



67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/docbook/xinclude_resolver.rb', line 67

def self.resolve_xml_include(doc, inc, file_path)
  included_xml = File.read(file_path)
  included_doc = Nokogiri::XML(included_xml,
                               "file://#{File.expand_path(file_path)}")

  root = included_doc.root
  # Ensure namespace is declared in target document
  if root.namespace
    ns = root.namespace
    existing = doc.root.namespace_definitions.find { |n| n.href == ns.href }
    doc.root.add_namespace_definition(ns.prefix, ns.href) unless existing
  end

  # Replace the xi:include with the root element itself (not its children)
  inc.replace(root.dup)
end