gitlab_markdown_helper.rb 6.74 KB
Newer Older
1 2
require 'nokogiri'

3
module GitlabMarkdownHelper
4 5 6 7 8 9 10 11 12
  # Use this in places where you would normally use link_to(gfm(...), ...).
  #
  # It solves a problem occurring with nested links (i.e.
  # "<a>outer text <a>gfm ref</a> more outer text</a>"). This will not be
  # interpreted as intended. Browsers will parse something like
  # "<a>outer text </a><a>gfm ref</a> more outer text" (notice the last part is
  # not linked any more). link_to_gfm corrects that. It wraps all parts to
  # explicitly produce the correct linking behavior (i.e.
  # "<a>outer text </a><a>gfm ref</a><a> more outer text</a>").
13
  def link_to_gfm(body, url, html_options = {})
14
    return "" if body.blank?
15

16
    escaped_body = if body =~ /\A\<img/
17 18 19 20 21
                     body
                   else
                     escape_once(body)
                   end

22 23
    user = current_user if defined?(current_user)
    gfm_body = Gitlab::Markdown.gfm(escaped_body, project: @project, current_user: user)
24

SAKATA Sinji's avatar
SAKATA Sinji committed
25
    fragment = Nokogiri::HTML::DocumentFragment.parse(gfm_body)
26 27 28 29 30 31 32 33 34 35 36 37
    if fragment.children.size == 1 && fragment.children[0].name == 'a'
      # Fragment has only one node, and it's a link generated by `gfm`.
      # Replace it with our requested link.
      text = fragment.children[0].text
      fragment.children[0].replace(link_to(text, url, html_options))
    else
      # Traverse the fragment's first generation of children looking for pure
      # text, wrapping anything found in the requested link
      fragment.children.each do |node|
        next unless node.text?
        node.replace(link_to(node.text, url, html_options))
      end
38 39
    end

40 41 42 43 44
    # Add any custom CSS classes to the GFM-generated reference links
    if html_options[:class]
      fragment.css('a.gfm').add_class(html_options[:class])
    end

45
    fragment.to_html.html_safe
46
  end
randx's avatar
randx committed
47

48
  def markdown(text, context = {})
Douwe Maan's avatar
Douwe Maan committed
49
    return "" unless text.present?
50

51
    context.reverse_merge!(
52
      path:         @path,
53
      pipeline:     :default,
54 55
      project:      @project,
      project_wiki: @project_wiki,
56
      ref:          @ref
57
    )
58

59 60
    user = current_user if defined?(current_user)

61
    html = Gitlab::Markdown.render(text, context)
62
    Gitlab::Markdown.post_process(html, pipeline: context[:pipeline], project: @project, user: user)
randx's avatar
randx committed
63
  end
64

65 66 67
  # TODO (rspeicher): Remove all usages of this helper and just call `markdown`
  # with a custom pipeline depending on the content being rendered
  def gfm(text, options = {})
Douwe Maan's avatar
Douwe Maan committed
68
    return "" unless text.present?
69

70
    options.reverse_merge!(
71
      path:         @path,
72
      pipeline:     :default,
73 74 75 76 77
      project:      @project,
      project_wiki: @project_wiki,
      ref:          @ref
    )

78 79
    user = current_user if defined?(current_user)

80
    html = Gitlab::Markdown.gfm(text, options)
81
    Gitlab::Markdown.post_process(html, pipeline: options[:pipeline], project: @project, user: user)
82 83
  end

84 85 86 87 88 89 90 91 92 93
  def asciidoc(text)
    Gitlab::Asciidoc.render(text, {
      commit: @commit,
      project: @project,
      project_wiki: @project_wiki,
      requested_path: @path,
      ref: @ref
    })
  end

94 95 96 97
  # Return the first line of +text+, up to +max_chars+, after parsing the line
  # as Markdown.  HTML tags in the parsed output are not counted toward the
  # +max_chars+ limit.  If the length limit falls within a tag's contents, then
  # the tag contents are truncated without removing the closing tag.
98 99
  def first_line_in_markdown(text, max_chars = nil, options = {})
    md = markdown(text, options).strip
100

101
    truncate_visible(md, max_chars || md.length) if md.present?
102 103
  end

104
  def render_wiki_content(wiki_page)
105 106
    case wiki_page.format
    when :markdown
107
      markdown(wiki_page.content)
108 109
    when :asciidoc
      asciidoc(wiki_page.content)
110 111 112 113
    else
      wiki_page.formatted_content.html_safe
    end
  end
114

115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
  MARKDOWN_TIPS = [
    "End a line with two or more spaces for a line-break, or soft-return",
    "Inline code can be denoted by `surrounding it with backticks`",
    "Blocks of code can be denoted by three backticks ``` or four leading spaces",
    "Emoji can be added by :emoji_name:, for example :thumbsup:",
    "Notify other participants using @user_name",
    "Notify a specific group using @group_name",
    "Notify the entire team using @all",
    "Reference an issue using a hash, for example issue #123",
    "Reference a merge request using an exclamation point, for example MR !123",
    "Italicize words or phrases using *asterisks* or _underscores_",
    "Bold words or phrases using **double asterisks** or __double underscores__",
    "Strikethrough words or phrases using ~~two tildes~~",
    "Make a bulleted list using + pluses, - minuses, or * asterisks",
    "Denote blockquotes using > at the beginning of a line",
    "Make a horizontal line using three or more hyphens ---, asterisks ***, or underscores ___"
  ].freeze

  # Returns a random markdown tip for use as a textarea placeholder
  def random_markdown_tip
Darby's avatar
Darby committed
135
    MARKDOWN_TIPS.sample
136 137
  end

138 139 140 141 142 143 144
  private

  # Return +text+, truncated to +max_chars+ characters, excluding any HTML
  # tags.
  def truncate_visible(text, max_chars)
    doc = Nokogiri::HTML.fragment(text)
    content_length = 0
145
    truncated = false
146 147 148

    doc.traverse do |node|
      if node.text? || node.content.empty?
149
        if truncated
150 151 152 153
          node.remove
          next
        end

154 155 156 157 158 159
        # Handle line breaks within a node
        if node.content.strip.lines.length > 1
          node.content = "#{node.content.lines.first.chomp}..."
          truncated = true
        end

160 161 162
        num_remaining = max_chars - content_length
        if node.content.length > num_remaining
          node.content = node.content.truncate(num_remaining)
163
          truncated = true
164 165 166
        end
        content_length += node.content.length
      end
167 168

      truncated = truncate_if_block(node, truncated)
169 170 171 172
    end

    doc.to_html
  end
173 174 175 176 177 178

  # Used by #truncate_visible.  If +node+ is the first block element, and the
  # text hasn't already been truncated, then append "..." to the node contents
  # and return true.  Otherwise return false.
  def truncate_if_block(node, truncated)
    if node.element? && node.description.block? && !truncated
179
      node.inner_html = "#{node.inner_html}..." if node.next_sibling
180 181 182 183 184
      true
    else
      truncated
    end
  end
185

186 187 188 189 190 191 192 193 194 195 196 197 198 199
  # Returns the text necessary to reference `entity` across projects
  #
  # project - Project to reference
  # entity  - Object that responds to `to_reference`
  #
  # Examples:
  #
  #   cross_project_reference(project, project.issues.first)
  #   # => 'namespace1/project1#123'
  #
  #   cross_project_reference(project, project.merge_requests.first)
  #   # => 'namespace1/project1!345'
  #
  # Returns a String
200
  def cross_project_reference(project, entity)
201 202
    if entity.respond_to?(:to_reference)
      "#{project.to_reference}#{entity.to_reference}"
203
    else
204
      ''
205 206
    end
  end
207
end