Module: ReleaseHx::Transforms::AdfToMarkdown

Defined in:
lib/releasehx/transforms/adf_to_markdown.rb

Overview

Converts Atlassian Document Format (ADF) to Markdown. Focused on extracting "Release Note" sections from Jira issue descriptions and converting them to clean Markdown for use in release documentation.

Class Method Summary collapse

Class Method Details

.adf?(obj) ⇒ Boolean

Checks if an object is an ADF document

Parameters:

  • obj (Object)
    The object to check

Returns:

  • (Boolean)
    true if obj is an ADF document


13
14
15
16
17
18
19
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 13

def self.adf? obj
  return false unless obj.is_a?(Hash)
  return false unless obj['type'] == 'doc'
  return false unless obj['version'] == 1

  obj.key?('content') && obj['content'].is_a?(Array)
end

.apply_marks(node) ⇒ Object

Apply marks (bold, italic, code, link) to text


270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 270

def self.apply_marks node
  text = node['text'] || ''
  marks = node['marks'] || []

  marks.each do |mark|
    case mark['type']
    when 'strong'
      text = "**#{text}**"
    when 'em'
      text = "_#{text}_"
    when 'code'
      text = "`#{text}`"
    when 'link'
      href = mark.dig('attrs', 'href') || ''
      text = "[#{text}](#{href})"
    when 'strike'
      text = "~~#{text}~~"
    when 'underline'
      # Markdown doesn't have native underline; use HTML
      text = "<u>#{text}</u>"
    end
  end

  text
end

.convert(adf_doc, options = {}) ⇒ String

Converts an ADF document (or fragment) to Markdown

Parameters:

  • adf_doc (Hash)
    The ADF document to convert
  • options (Hash) (defaults to: {})
    Conversion options

Options Hash (options):

  • :exclude_nodes (Array<String>)
    Node types to exclude

Returns:

  • (String)
    The Markdown representation


65
66
67
68
69
70
71
72
73
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 65

def self.convert adf_doc, options = {}
  return '' unless adf?(adf_doc)

  excluded = options[:exclude_nodes] || default_excluded_nodes
  content = adf_doc['content'] || []

  converted = content.map { |node| convert_node(node, excluded) }
  converted.join.strip
end

.convert_blockquote(node, excluded) ⇒ Object

Converts a blockquote


185
186
187
188
189
190
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 185

def self.convert_blockquote node, excluded
  content = node['content'] || []
  lines = content.map { |n| convert_node(n, excluded).strip }.join("\n")
  quoted = lines.split("\n").map { |line| "> #{line}" }.join("\n")
  "#{quoted}\n\n"
end

.convert_code_block(node) ⇒ Object

Converts a code block


176
177
178
179
180
181
182
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 176

def self.convert_code_block node
  lang = node.dig('attrs', 'language') || ''
  content = node['content'] || []
  code = content.map { |n| n['type'] == 'text' ? n['text'] : '' }.join

  "```#{lang}\n#{code}\n```\n\n"
end

.convert_list(node, excluded, depth, unordered: true) ⇒ Object

Converts a list (bullet or ordered)


138
139
140
141
142
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 138

def self.convert_list node, excluded, depth, unordered: true
  content = node['content'] || []
  items = content.map { |item| convert_node(item, excluded, depth + 1) }
  "#{items.join}\n"
end

.convert_list_item(node, excluded, depth) ⇒ Object

Converts a list item


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
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 145

def self.convert_list_item node, excluded, depth
  content = node['content'] || []
  indent = '  ' * (depth - 1)
  marker = '- '

  # Separate paragraph content from nested lists
  paragraphs = []
  nested_lists = []

  content.each do |n|
    if n['type'] == 'paragraph'
      paragraphs << convert_paragraph(n, excluded).strip
    elsif %w[bulletList orderedList].include?(n['type'])
      nested_lists << convert_node(n, excluded, depth)
    else
      paragraphs << convert_node(n, excluded, depth).strip
    end
  end

  # Build the list item line
  result = "#{indent}#{marker}#{paragraphs.join(' ')}\n"

  # Add nested lists on new lines with proper indentation
  nested_lists.each do |nested|
    result += nested
  end

  result
end

.convert_node(node, excluded = [], depth = 0) ⇒ String

Converts a single ADF node to Markdown

Parameters:

  • node (Hash)
    The ADF node
  • excluded (Array<String>) (defaults to: [])
    Node types to exclude
  • depth (Integer) (defaults to: 0)
    Current nesting depth for lists

Returns:

  • (String)
    The Markdown representation


86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 86

def self.convert_node node, excluded = [], depth = 0
  return '' unless node.is_a?(Hash)
  return '' if excluded.include?(node['type'])

  case node['type']
  when 'doc'
    content = node['content'] || []
    content.map { |n| convert_node(n, excluded, depth) }.join
  when 'paragraph'
    "#{convert_paragraph(node, excluded)}\n\n"
  when 'bulletList'
    convert_list(node, excluded, depth, unordered: true)
  when 'orderedList'
    convert_list(node, excluded, depth, unordered: false)
  when 'listItem'
    convert_list_item(node, excluded, depth)
  when 'codeBlock'
    convert_code_block(node)
  when 'blockquote'
    convert_blockquote(node, excluded)
  when 'panel'
    convert_panel(node, excluded)
  when 'rule'
    "\n---\n\n"
  when 'table'
    convert_table(node, excluded)
  when 'tableRow'
    convert_table_row(node, excluded)
  when 'tableHeader', 'tableCell'
    convert_table_cell(node, excluded)
  when 'text'
    apply_marks(node)
  when 'hardBreak'
    "  \n"
  when 'taskList'
    convert_task_list(node, excluded, depth)
  when 'taskItem'
    convert_task_item(node, excluded, depth)
  else
    # For unknown nodes, try to extract text content
    ReleaseHx.logger.debug "Skipping unsupported ADF node type: #{node['type']}"
    extract_text_from_node(node)
  end
end

.convert_panel(node, excluded) ⇒ Object

Converts a panel to a blockquote with admonition label


193
194
195
196
197
198
199
200
201
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 193

def self.convert_panel node, excluded
  panel_type = node.dig('attrs', 'panelType') || 'info'
  label = panel_type_to_label(panel_type)

  content = node['content'] || []
  text = content.map { |n| convert_node(n, excluded).strip }.join("\n")

  "> **#{label}:** #{text}\n\n"
end

.convert_paragraph(node, excluded) ⇒ Object

Converts a paragraph node


132
133
134
135
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 132

def self.convert_paragraph node, excluded
  content = node['content'] || []
  content.map { |n| convert_node(n, excluded) }.join
end

.convert_table(node, excluded) ⇒ Object

Converts a table (basic GFM table support)


215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 215

def self.convert_table node, excluded
  content = node['content'] || []
  return '' if content.empty?

  # Check if first row contains headers
  first_row = content[0]
  has_header = first_row && first_row['content']&.any? { |cell| cell['type'] == 'tableHeader' }

  rows = content.map { |row| convert_node(row, excluded) }

  if has_header
    header = rows[0]
    # Create separator row
    col_count = first_row['content']&.length || 0
    separator = "|#{' --- |' * col_count}\n"
    table_body = rows[1..].join

    "#{header}#{separator}#{table_body}\n"
  else
    "#{rows.join}\n"
  end
end

.convert_table_cell(node, excluded) ⇒ Object

Converts a table cell


246
247
248
249
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 246

def self.convert_table_cell node, excluded
  content = node['content'] || []
  content.map { |n| convert_node(n, excluded).strip }.join(' ')
end

.convert_table_row(node, excluded) ⇒ Object

Converts a table row


239
240
241
242
243
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 239

def self.convert_table_row node, excluded
  content = node['content'] || []
  cells = content.map { |cell| convert_node(cell, excluded) }
  "| #{cells.join(' | ')} |\n"
end

.convert_task_item(node, excluded, depth) ⇒ Object

Converts a task item


258
259
260
261
262
263
264
265
266
267
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 258

def self.convert_task_item node, excluded, depth
  state = node.dig('attrs', 'state')
  marker = state == 'DONE' ? '[x]' : '[ ]'
  indent = '  ' * (depth - 1)

  content = node['content'] || []
  text = content.map { |n| convert_node(n, excluded, depth).strip }.join(' ')

  "#{indent}- #{marker} #{text}\n"
end

.convert_task_list(node, excluded, depth) ⇒ Object

Converts a task list


252
253
254
255
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 252

def self.convert_task_list node, excluded, depth
  content = node['content'] || []
  content.map { |item| convert_node(item, excluded, depth + 1) }.join
end

.default_excluded_nodesObject

Default nodes to exclude (headings, media, mentions, etc.)


76
77
78
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 76

def self.default_excluded_nodes
  %w[heading media mediaGroup mediaSingle mediaInline mention emoji status inlineCard blockCard date]
end

.extract_section(adf_doc, heading: 'Release Note') ⇒ Hash

Extracts a specific section from an ADF document by heading text

Parameters:

  • adf_doc (Hash)
    The ADF document
  • heading (String) (defaults to: 'Release Note')
    The heading text to search for (case-insensitive)

Returns:

  • (Hash)
    A new ADF document containing only the extracted section


26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 26

def self.extract_section adf_doc, heading: 'Release Note'
  return adf_doc unless adf?(adf_doc)

  content = adf_doc['content'] || []
  heading_normalized = heading.strip.downcase

  # Find the heading index
  heading_idx = content.find_index do |node|
    node['type'] == 'heading' &&
      extract_text_from_node(node).strip.downcase == heading_normalized
  end

  return { 'type' => 'doc', 'version' => 1, 'content' => [] } unless heading_idx

  # Extract nodes after the heading until next same-level or higher heading
  heading_level = content[heading_idx].dig('attrs', 'level') || 1
  section_content = []

  ((heading_idx + 1)...content.length).each do |i|
    node = content[i]

    # Stop if we hit another heading at same or higher level
    if node['type'] == 'heading'
      node_level = node.dig('attrs', 'level') || 1
      break if node_level <= heading_level
    end

    section_content << node
  end

  { 'type' => 'doc', 'version' => 1, 'content' => section_content }
end

.extract_text_from_node(node) ⇒ Object

Extract plain text from any node (recursive)


297
298
299
300
301
302
303
304
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 297

def self.extract_text_from_node node
  return '' unless node.is_a?(Hash)

  return node['text'] || '' if node['type'] == 'text'

  content = node['content'] || []
  content.map { |n| extract_text_from_node(n) }.join
end

.panel_type_to_label(panel_type) ⇒ Object

Maps panel types to admonition labels


204
205
206
207
208
209
210
211
212
# File 'lib/releasehx/transforms/adf_to_markdown.rb', line 204

def self.panel_type_to_label panel_type
  {
    'info' => 'NOTE',
    'note' => 'NOTE',
    'warning' => 'WARNING',
    'error' => 'CAUTION',
    'success' => 'TIP'
  }[panel_type] || 'NOTE'
end