382 lines
14 KiB
Ruby
382 lines
14 KiB
Ruby
|
# frozen_string_literal: true
|
||
|
|
||
|
module Kramdown
|
||
|
module Parser
|
||
|
# Parses an Atlassian Document Format (ADF) json into a
|
||
|
# Kramdown AST tree, for conversion to another format.
|
||
|
# The primary goal is to convert in GitLab Markdown.
|
||
|
#
|
||
|
# This parser does NOT resolve external resources, such as media/attachments.
|
||
|
# A special url is generated for media based on the id, for example
|
||
|
# ![jira-10050-field-description](adf-media://79411c6b-50e0-477f-b4ed-ac3a5887750c)
|
||
|
# so that a later filter/process can resolve those.
|
||
|
#
|
||
|
# @see https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/ ADF Document Structure
|
||
|
# @see https://developer.atlassian.com/cloud/jira/platform/apis/document/playground/ ADF Playground
|
||
|
# @see https://developer.atlassian.com/cloud/jira/platform/apis/document/viewer/ ADF Viewer
|
||
|
class AtlassianDocumentFormat < Kramdown::Parser::Base
|
||
|
unless defined?(TOP_LEVEL_BLOCK_NODES)
|
||
|
TOP_LEVEL_BLOCK_NODES = %w[blockquote
|
||
|
bulletList
|
||
|
codeBlock
|
||
|
heading
|
||
|
mediaGroup
|
||
|
mediaSingle
|
||
|
orderedList
|
||
|
panel
|
||
|
paragraph
|
||
|
rule
|
||
|
table].freeze
|
||
|
|
||
|
CHILD_BLOCK_NODES = %w[listItem
|
||
|
media
|
||
|
table_cell
|
||
|
table_header
|
||
|
table_row].freeze
|
||
|
|
||
|
INLINE_NODES = %w[emoji
|
||
|
hardBreak
|
||
|
inlineCard
|
||
|
mention
|
||
|
text].freeze
|
||
|
|
||
|
MARKS = %w[code
|
||
|
em
|
||
|
link
|
||
|
strike
|
||
|
strong
|
||
|
subsup
|
||
|
textColor
|
||
|
underline].freeze
|
||
|
|
||
|
TABLE_CELL_NODES = %w[blockquote
|
||
|
bulletList
|
||
|
codeBlock
|
||
|
heading
|
||
|
mediaGroup
|
||
|
orderedList
|
||
|
panel
|
||
|
paragraph
|
||
|
rule].freeze
|
||
|
|
||
|
LIST_ITEM_NODES = %w[bulletList
|
||
|
codeBlock
|
||
|
mediaSingle
|
||
|
orderedList
|
||
|
paragraph].freeze
|
||
|
|
||
|
PANEL_NODES = %w[bulletList
|
||
|
heading
|
||
|
orderedList
|
||
|
paragraph].freeze
|
||
|
|
||
|
PANEL_EMOJIS = { info: ':information_source:',
|
||
|
note: ':notepad_spiral:',
|
||
|
warning: ':warning:',
|
||
|
success: ':white_check_mark:',
|
||
|
error: ':octagonal_sign:' }.freeze
|
||
|
|
||
|
# The default language for code blocks is `java`, as indicated in
|
||
|
# You can't change the default in Jira. There was a comment that indicated
|
||
|
# Confluence can set the default language.
|
||
|
# @see https://jira.atlassian.com/secure/WikiRendererHelpAction.jspa?section=advanced&_ga=2.5135221.773220073.1591894917-438867908.1591894917
|
||
|
# @see https://jira.atlassian.com/browse/JRASERVER-29184?focusedCommentId=832255&page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel#comment-832255
|
||
|
CODE_BLOCK_DEFAULT_LANGUAGE = 'java'
|
||
|
end
|
||
|
|
||
|
def parse
|
||
|
ast = Gitlab::Json.parse(@source)
|
||
|
|
||
|
validate_document(ast)
|
||
|
|
||
|
process_content(@root, ast, TOP_LEVEL_BLOCK_NODES)
|
||
|
rescue ::JSON::ParserError => e
|
||
|
msg = 'Invalid Atlassian Document Format JSON'
|
||
|
Gitlab::AppLogger.error msg
|
||
|
Gitlab::AppLogger.error e
|
||
|
|
||
|
raise ::Kramdown::Error, msg
|
||
|
end
|
||
|
|
||
|
def process_content(element, ast_node, allowed_types)
|
||
|
ast_node['content'].each do |node|
|
||
|
next unless allowed_types.include?(node['type'])
|
||
|
|
||
|
public_send("process_#{node['type'].underscore}", element, node) # rubocop:disable GitlabSecurity/PublicSend
|
||
|
end
|
||
|
end
|
||
|
|
||
|
def process_blockquote(element, ast_node)
|
||
|
new_element = Element.new(:blockquote)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, TOP_LEVEL_BLOCK_NODES)
|
||
|
end
|
||
|
|
||
|
def process_bullet_list(element, ast_node)
|
||
|
new_element = Element.new(:ul)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, %w[listItem])
|
||
|
end
|
||
|
|
||
|
def process_code_block(element, ast_node)
|
||
|
code_text = gather_text(ast_node)
|
||
|
lang = ast_node.dig('attrs', 'language') || CODE_BLOCK_DEFAULT_LANGUAGE
|
||
|
|
||
|
element.children << Element.new(:codeblock, code_text, {}, { lang: lang })
|
||
|
end
|
||
|
|
||
|
def process_emoji(element, ast_node)
|
||
|
emoji = ast_node.dig('attrs', 'text') || ast_node.dig('attrs', 'shortName')
|
||
|
return unless emoji
|
||
|
|
||
|
add_text(emoji, element, :text)
|
||
|
end
|
||
|
|
||
|
def process_hard_break(element, ast_node)
|
||
|
element.children << Element.new(:br)
|
||
|
end
|
||
|
|
||
|
def process_heading(element, ast_node)
|
||
|
level = ast_node.dig('attrs', 'level').to_i.clamp(1, 6)
|
||
|
options = { level: level }
|
||
|
new_element = Element.new(:header, nil, nil, options)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, INLINE_NODES)
|
||
|
extract_element_text(new_element, new_element.options[:raw_text] = +'')
|
||
|
end
|
||
|
|
||
|
def process_inline_card(element, ast_node)
|
||
|
url = ast_node.dig('attrs', 'url')
|
||
|
data = ast_node.dig('attrs', 'data')
|
||
|
|
||
|
if url
|
||
|
# we don't pull a description from the link and create a panel,
|
||
|
# just convert to a normal link
|
||
|
new_element = Element.new(:text, url)
|
||
|
element.children << wrap_element(new_element, :a, nil, { 'href' => url })
|
||
|
elsif data
|
||
|
# data is JSONLD (https://json-ld.org/), so for now output
|
||
|
# as a codespan of text, with `adf-inlineCard: ` at the start
|
||
|
text = "adf-inlineCard: #{data}"
|
||
|
element.children << Element.new(:codespan, text, nil, { lang: 'adf-inlinecard' })
|
||
|
end
|
||
|
end
|
||
|
|
||
|
def process_list_item(element, ast_node)
|
||
|
new_element = Element.new(:li)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, LIST_ITEM_NODES)
|
||
|
end
|
||
|
|
||
|
def process_media(element, ast_node)
|
||
|
media_url = "adf-media://#{ast_node['attrs']['id']}"
|
||
|
|
||
|
case ast_node['attrs']['type']
|
||
|
when 'file'
|
||
|
attrs = { 'src' => media_url, 'alt' => ast_node['attrs']['collection'] }
|
||
|
media_element = Element.new(:img, nil, attrs)
|
||
|
when 'link'
|
||
|
attrs = { 'href' => media_url }
|
||
|
media_element = wrap_element(Element.new(:text, media_url), :a, nil, attrs)
|
||
|
end
|
||
|
|
||
|
media_element = wrap_element(media_element, :p)
|
||
|
element.children << media_element
|
||
|
end
|
||
|
|
||
|
# wraps a single media element.
|
||
|
# Currently ignore attrs.layout and attrs.width
|
||
|
def process_media_single(element, ast_node)
|
||
|
new_element = Element.new(:p)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, %w[media])
|
||
|
end
|
||
|
|
||
|
# wraps a group media element.
|
||
|
# Currently ignore attrs.layout and attrs.width
|
||
|
def process_media_group(element, ast_node)
|
||
|
ul_element = Element.new(:ul)
|
||
|
element.children << ul_element
|
||
|
|
||
|
ast_node['content'].each do |node|
|
||
|
next unless node['type'] == 'media'
|
||
|
|
||
|
li_element = Element.new(:li)
|
||
|
ul_element.children << li_element
|
||
|
|
||
|
process_media(li_element, node)
|
||
|
end
|
||
|
end
|
||
|
|
||
|
def process_mention(element, ast_node)
|
||
|
# Make it `@adf-mention:` since there is no guarantee that it is
|
||
|
# a valid username in our system. This gives us an
|
||
|
# opportunity to replace it later. Mention name can have
|
||
|
# spaces, so double quote it
|
||
|
mention_text = ast_node.dig('attrs', 'text')&.gsub('@', '')
|
||
|
mention_text = %Q("#{mention_text}") if mention_text.match?(/ /)
|
||
|
mention_text = %Q(@adf-mention:#{mention_text})
|
||
|
|
||
|
add_text(mention_text, element, :text)
|
||
|
end
|
||
|
|
||
|
def process_ordered_list(element, ast_node)
|
||
|
# `attrs.order` is not supported in the Kramdown AST
|
||
|
new_element = Element.new(:ol)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, %w[listItem])
|
||
|
end
|
||
|
|
||
|
# since we don't have something similar, then put <hr> around it and
|
||
|
# add a bolded status text (eg: "Error:") to the front of it.
|
||
|
def process_panel(element, ast_node)
|
||
|
panel_type = ast_node.dig('attrs', 'panelType')
|
||
|
return unless %w[info note warning success error].include?(panel_type)
|
||
|
|
||
|
panel_header_text = "#{PANEL_EMOJIS[panel_type.to_sym]} "
|
||
|
panel_header_element = Element.new(:text, panel_header_text)
|
||
|
|
||
|
new_element = Element.new(:blockquote)
|
||
|
new_element.children << panel_header_element
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, PANEL_NODES)
|
||
|
end
|
||
|
|
||
|
def process_paragraph(element, ast_node)
|
||
|
new_element = Element.new(:p)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, INLINE_NODES)
|
||
|
end
|
||
|
|
||
|
def process_rule(element, ast_node)
|
||
|
element.children << Element.new(:hr)
|
||
|
end
|
||
|
|
||
|
def process_table(element, ast_node)
|
||
|
table = Element.new(:table, nil, nil, { alignment: [:default, :default] })
|
||
|
element.children << table
|
||
|
|
||
|
tbody = Element.new(:tbody)
|
||
|
table.children << tbody
|
||
|
|
||
|
process_content(tbody, ast_node, %w[tableRow])
|
||
|
end
|
||
|
|
||
|
# we ignore the attributes, attrs.background, attrs.colspan,
|
||
|
# attrs.colwidth, and attrs.rowspan
|
||
|
def process_table_cell(element, ast_node)
|
||
|
new_element = Element.new(:td)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, TABLE_CELL_NODES)
|
||
|
end
|
||
|
|
||
|
# we ignore the attributes, attrs.background, attrs.colspan,
|
||
|
# attrs.colwidth, and attrs.rowspan
|
||
|
def process_table_header(element, ast_node)
|
||
|
new_element = Element.new(:th)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, TABLE_CELL_NODES)
|
||
|
end
|
||
|
|
||
|
def process_table_row(element, ast_node)
|
||
|
new_element = Element.new(:tr)
|
||
|
element.children << new_element
|
||
|
|
||
|
process_content(new_element, ast_node, %w[tableHeader tableCell])
|
||
|
end
|
||
|
|
||
|
def process_text(element, ast_node)
|
||
|
new_element = Element.new(:text, ast_node['text'])
|
||
|
new_element = apply_marks(new_element, ast_node, MARKS)
|
||
|
element.children << new_element
|
||
|
end
|
||
|
|
||
|
private
|
||
|
|
||
|
def validate_document(ast)
|
||
|
return if ast['type'] == 'doc'
|
||
|
|
||
|
raise ::JSON::ParserError, 'missing doc node'
|
||
|
end
|
||
|
|
||
|
# ADF marks are an attribute on the node. For kramdown,
|
||
|
# we have to wrap the node with an element for the mark.
|
||
|
def apply_marks(element, ast_node, allowed_types)
|
||
|
return element unless ast_node['marks']
|
||
|
|
||
|
new_element = element
|
||
|
|
||
|
ast_node['marks'].each do |mark|
|
||
|
next unless allowed_types.include?(mark['type'])
|
||
|
|
||
|
case mark['type']
|
||
|
when 'code'
|
||
|
new_element = Element.new(:codespan, ast_node['text'])
|
||
|
when 'em'
|
||
|
new_element = wrap_element(new_element, :em)
|
||
|
when 'link'
|
||
|
attrs = { 'href' => mark.dig('attrs', 'href') }
|
||
|
attrs['title'] = mark.dig('attrs', 'title')
|
||
|
new_element = wrap_element(new_element, :a, nil, attrs)
|
||
|
when 'strike'
|
||
|
new_element = wrap_element(new_element, :html_element, 'del', {}, category: :span)
|
||
|
when 'strong'
|
||
|
new_element = wrap_element(new_element, :strong)
|
||
|
when 'subsup'
|
||
|
type = mark.dig('attrs', 'type')
|
||
|
|
||
|
case type
|
||
|
when 'sub'
|
||
|
new_element = wrap_element(new_element, :html_element, 'sub', {}, category: :span)
|
||
|
when 'sup'
|
||
|
new_element = wrap_element(new_element, :html_element, 'sup', {}, category: :span)
|
||
|
else
|
||
|
next
|
||
|
end
|
||
|
when 'textColor'
|
||
|
color = mark.dig('attrs', 'color')
|
||
|
new_element = wrap_element(new_element, :html_element, 'span', { color: color }, category: :span)
|
||
|
when 'underline'
|
||
|
new_element = wrap_element(new_element, :html_element, 'u', {}, category: :span)
|
||
|
else
|
||
|
next
|
||
|
end
|
||
|
end
|
||
|
|
||
|
new_element
|
||
|
end
|
||
|
|
||
|
def wrap_element(element, type, *args)
|
||
|
wrapper = Element.new(type, *args)
|
||
|
wrapper.children << element
|
||
|
wrapper
|
||
|
end
|
||
|
|
||
|
def extract_element_text(element, raw)
|
||
|
raw << element.value.to_s if element.type == :text
|
||
|
element.children.each { |c| extract_element_text(c, raw) }
|
||
|
end
|
||
|
|
||
|
def gather_text(ast_node)
|
||
|
ast_node['content'].inject('') do |memo, node|
|
||
|
node['type'] == 'text' ? (memo + node['text']) : memo
|
||
|
end
|
||
|
end
|
||
|
|
||
|
def method_missing(method, *args)
|
||
|
raise NotImplementedError, "method `#{method}` not implemented yet"
|
||
|
end
|
||
|
end
|
||
|
end
|
||
|
end
|