# 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