gitlab_markdown_helper.rb 8.41 KB
Newer Older
1
module GitlabMarkdownHelper
2
  include Gitlab::Markdown
3

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 17 18 19 20 21
    escaped_body = if body =~ /^\<img/
                     body
                   else
                     escape_once(body)
                   end

skv's avatar
skv committed
22
    gfm_body = gfm(escaped_body, @project, html_options)
23 24 25 26 27 28 29

    gfm_body.gsub!(%r{<a.*?>.*?</a>}m) do |match|
      "</a>#{match}#{link_to("", url, html_options)[0..-5]}" # "</a>".length +1
    end

    link_to(gfm_body.html_safe, url, html_options)
  end
randx's avatar
randx committed
30

31 32 33 34 35 36 37 38 39
  def markdown(text, options={})
    unless (@markdown and options == @options)
      @options = options
      gitlab_renderer = Redcarpet::Render::GitlabHTML.new(self, {
                            # see https://github.com/vmg/redcarpet#darling-i-packed-you-a-couple-renderers-for-lunch-
                            filter_html: true,
                            with_toc_data: true,
                            safe_links_only: true
                          }.merge(options))
40
      @markdown = Redcarpet::Markdown.new(gitlab_renderer,
41 42 43 44 45 46
                      # see https://github.com/vmg/redcarpet#and-its-like-really-simple-to-use
                      no_intra_emphasis: true,
                      tables: true,
                      fenced_code_blocks: true,
                      autolink: true,
                      strikethrough: true,
47
                      lax_spacing: true,
48 49 50
                      space_after_headers: true,
                      superscript: true)
    end
Sacred Seven's avatar
Sacred Seven committed
51 52 53
    html_doc = Nokogiri::HTML(@markdown.render(text))
    html_doc.css(':not(li)').each { |dom| dom[:dir] = :auto }
    html_doc.to_html.html_safe
randx's avatar
randx committed
54
  end
55

56 57 58 59 60
  # 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.
  def first_line_in_markdown(text, max_chars = nil)
61
    md = markdown(text).strip
62

63
    truncate_visible(md, max_chars || md.length) if md.present?
64 65
  end

66 67 68 69 70 71 72
  def render_wiki_content(wiki_page)
    if wiki_page.format == :markdown
      markdown(wiki_page.content)
    else
      wiki_page.formatted_content.html_safe
    end
  end
73

74
  def create_relative_links(text)
75
    paths = extract_paths(text)
Marin Jankovski's avatar
Marin Jankovski committed
76

77
    paths.uniq.each do |file_path|
78 79
      # If project does not have repository
      # its nothing to rebuild
80 81 82 83 84 85
      #
      # TODO: pass project variable to markdown helper instead of using
      # instance variable. Right now it generates invalid path for pages out
      # of project scope. Example: search results where can be rendered markdown
      # from different projects
      if @repository && @repository.exists? && !@repository.empty?
86 87 88 89 90
        new_path = rebuild_path(file_path)
        # Finds quoted path so we don't replace other mentions of the string
        # eg. "doc/api" will be replaced and "/home/doc/api/text" won't
        text.gsub!("\"#{file_path}\"", "\"/#{new_path}\"")
      end
91
    end
Marin Jankovski's avatar
Marin Jankovski committed
92

93
    text
94 95
  end

96 97 98 99
  def extract_paths(text)
    links = substitute_links(text)
    image_links = substitute_image_links(text)
    links + image_links
100 101
  end

102 103 104 105
  def substitute_links(text)
    links = text.scan(/<a href=\"([^"]*)\">/)
    relative_links = links.flatten.reject{ |link| link_to_ignore? link }
    relative_links
106 107
  end

108 109 110 111
  def substitute_image_links(text)
    links = text.scan(/<img src=\"([^"]*)\"/)
    relative_links = links.flatten.reject{ |link| link_to_ignore? link }
    relative_links
112 113
  end

114
  def link_to_ignore?(link)
115 116 117 118 119 120
    if link =~ /\#\w+/
      # ignore anchors like <a href="#my-header">
      true
    else
      ignored_protocols.map{ |protocol| link.include?(protocol) }.any?
    end
121 122
  end

123 124 125 126
  def ignored_protocols
    ["http://","https://", "ftp://", "mailto:"]
  end

127
  def rebuild_path(path)
128 129
    path.gsub!(/(#.*)/, "")
    id = $1 || ""
130
    file_path = relative_file_path(path)
131 132
    file_path = sanitize_slashes(file_path)

Marin Jankovski's avatar
Marin Jankovski committed
133
    [
134 135 136
      Gitlab.config.gitlab.relative_url_root,
      @project.path_with_namespace,
      path_with_ref(file_path),
Marin Jankovski's avatar
Marin Jankovski committed
137
      file_path
138
    ].compact.join("/").gsub(/^\/*|\/*$/, '') + id
Marin Jankovski's avatar
Marin Jankovski committed
139 140
  end

141 142 143 144 145 146
  def sanitize_slashes(path)
    path[0] = "" if path.start_with?("/")
    path.chop if path.end_with?("/")
    path
  end

147 148
  def relative_file_path(path)
    requested_path = @path
Marin Jankovski's avatar
Marin Jankovski committed
149 150 151 152 153 154 155 156
    nested_path = build_nested_path(path, requested_path)
    return nested_path if file_exists?(nested_path)
    path
  end

  # Covering a special case, when the link is referencing file in the same directory eg:
  # If we are at doc/api/README.md and the README.md contains relative links like [Users](users.md)
  # this takes the request path(doc/api/README.md), and replaces the README.md with users.md so the path looks like doc/api/users.md
157
  # If we are at doc/api and the README.md shown in below the tree view
158
  # this takes the request path(doc/api) and adds users.md so the path looks like doc/api/users.md
Marin Jankovski's avatar
Marin Jankovski committed
159
  def build_nested_path(path, request_path)
160
    return request_path if path == ""
Marin Jankovski's avatar
Marin Jankovski committed
161
    return path unless request_path
162 163 164 165 166 167 168 169
    if local_path(request_path) == "tree"
      base = request_path.split("/").push(path)
      base.join("/")
    else
      base = request_path.split("/")
      base.pop
      base.push(path).join("/")
    end
Marin Jankovski's avatar
Marin Jankovski committed
170 171
  end

172 173 174 175 176 177 178 179 180 181
  # Checks if the path exists in the repo
  # eg. checks if doc/README.md exists, if not then link to blob
  def path_with_ref(path)
    if file_exists?(path)
      "#{local_path(path)}/#{correct_ref}"
    else
      "blob/#{correct_ref}"
    end
  end

Marin Jankovski's avatar
Marin Jankovski committed
182
  def file_exists?(path)
183
    return false if path.nil?
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
184
    return @repository.blob_at(current_sha, path).present? || @repository.tree(current_sha, path).entries.any?
Marin Jankovski's avatar
Marin Jankovski committed
185 186
  end

187 188 189
  # Check if the path is pointing to a directory(tree) or a file(blob)
  # eg. doc/api is directory and doc/README.md is file
  def local_path(path)
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
190 191
    return "tree" if @repository.tree(current_sha, path).entries.any?
    return "raw" if @repository.blob_at(current_sha, path).image?
192
    return "blob"
Marin Jankovski's avatar
Marin Jankovski committed
193 194
  end

Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
195 196 197
  def current_sha
    if @commit
      @commit.id
198
    elsif @repository && !@repository.empty?
199 200 201 202 203
      if @ref
        @repository.commit(@ref).try(:sha)
      else
        @repository.head_commit.sha
      end
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
204 205 206
    end
  end

207
  # We will assume that if no ref exists we can point to master
208 209
  def correct_ref
    @ref ? @ref : "master"
Marin Jankovski's avatar
Marin Jankovski committed
210
  end
211 212 213 214 215 216 217 218

  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
219
    truncated = false
220 221 222

    doc.traverse do |node|
      if node.text? || node.content.empty?
223
        if truncated
224 225 226 227
          node.remove
          next
        end

228 229 230 231 232 233
        # Handle line breaks within a node
        if node.content.strip.lines.length > 1
          node.content = "#{node.content.lines.first.chomp}..."
          truncated = true
        end

234 235 236
        num_remaining = max_chars - content_length
        if node.content.length > num_remaining
          node.content = node.content.truncate(num_remaining)
237
          truncated = true
238 239 240
        end
        content_length += node.content.length
      end
241 242

      truncated = truncate_if_block(node, truncated)
243 244 245 246
    end

    doc.to_html
  end
247 248 249 250 251 252 253 254 255 256 257 258

  # 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
      node.content = "#{node.content}..." if node.next_sibling
      true
    else
      truncated
    end
  end
259 260 261 262 263 264 265 266 267 268 269 270

  def cross_project_reference(project, entity)
    path = project.path_with_namespace

    if entity.kind_of?(Issue)
      [path, entity.iid].join('#')
    elsif entity.kind_of?(MergeRequest)
      [path, entity.iid].join('!')
    else
      raise 'Not supported type'
    end
  end
271
end