markdown.rb 11.7 KB
Newer Older
1 2 3
require 'html/pipeline'
require 'html/pipeline/gitlab'

4
module Gitlab
5
  # Custom parser for GitLab-flavored Markdown
6
  #
7
  # It replaces references in the text with links to the appropriate items in
8
  # GitLab.
9 10 11 12
  #
  # Supported reference formats are:
  #   * @foo for team members
  #   * #123 for issues
13
  #   * #JIRA-123 for Jira issues
14 15 16
  #   * !123 for merge requests
  #   * $123 for snippets
  #   * 123456 for commits
17
  #   * 123456...7890123 for commit ranges (comparisons)
18
  #
19 20
  # It also parses Emoji codes to insert images. See
  # http://www.emoji-cheat-sheet.com/ for a list of the supported icons.
21
  #
22
  # Examples
23
  #
24
  #   >> gfm("Hey @david, can you fix this?")
Martin Bastien's avatar
Martin Bastien committed
25
  #   => "Hey <a href="/u/david">@david</a>, can you fix this?"
26
  #
27
  #   >> gfm("Commit 35d5f7c closes #1234")
28
  #   => "Commit <a href="/gitlab/commits/35d5f7c">35d5f7c</a> closes <a href="/gitlab/issues/1234">#1234</a>"
29 30 31 32
  #
  #   >> gfm(":trollface:")
  #   => "<img alt=\":trollface:\" class=\"emoji\" src=\"/images/trollface.png" title=\":trollface:\" />
  module Markdown
33 34
    include IssuesHelper

35 36
    attr_reader :html_options

37 38 39 40 41
    def gfm_with_tasks(text, project = @project, html_options = {})
      text = gfm(text, project, html_options)
      parse_tasks(text)
    end

42 43 44
    # Public: Parse the provided text with GitLab-Flavored Markdown
    #
    # text         - the source text
skv's avatar
skv committed
45
    # project      - extra options for the reference links as given to link_to
46
    # html_options - extra options for the reference links as given to link_to
skv's avatar
skv committed
47
    def gfm(text, project = @project, html_options = {})
48 49
      return text if text.nil?

50 51 52 53
      # Duplicate the string so we don't alter the original, then call to_str
      # to cast it back to a String instead of a SafeBuffer. This is required
      # for gsub calls to work as we need them to.
      text = text.dup.to_str
54

55
      @html_options = html_options
56 57 58

      # Extract pre blocks so they are not altered
      # from http://github.github.com/github-flavored-markdown/
59 60 61 62 63
      text.gsub!(%r{<pre>.*?</pre>|<code>.*?</code>}m) { |match| extract_piece(match) }
      # Extract links with probably parsable hrefs
      text.gsub!(%r{<a.*?>.*?</a>}m) { |match| extract_piece(match) }
      # Extract images with probably parsable src
      text.gsub!(%r{<img.*?>}m) { |match| extract_piece(match) }
64 65 66

      # TODO: add popups with additional information

skv's avatar
skv committed
67
      text = parse(text, project)
68 69 70

      # Insert pre block extractions
      text.gsub!(/\{gfm-extraction-(\h{32})\}/) do
71
        insert_piece($1)
72 73
      end

74 75 76 77 78 79 80
      # Used markdown pipelines in GitLab:
      # GitlabEmojiFilter - performs emoji replacement.
      #
      # see https://gitlab.com/gitlab-org/html-pipeline-gitlab for more filters
      filters = [
        HTML::Pipeline::Gitlab::GitlabEmojiFilter
      ]
81

82 83 84 85 86
      markdown_context = {
              asset_root: Gitlab.config.gitlab.url,
              asset_host: Gitlab::Application.config.asset_host
      }

87 88
      markdown_pipeline = HTML::Pipeline::Gitlab.new(filters).pipeline

89
      result = markdown_pipeline.call(text, markdown_context)
90 91
      text = result[:output].to_html(save_with: 0)

skv's avatar
skv committed
92 93 94 95
      allowed_attributes = ActionView::Base.sanitized_allowed_attributes
      allowed_tags = ActionView::Base.sanitized_allowed_tags

      sanitize text.html_safe,
Nikita Verkhovin's avatar
Nikita Verkhovin committed
96
               attributes: allowed_attributes + %w(id class style),
skv's avatar
skv committed
97
               tags: allowed_tags + %w(table tr td th)
98 99
    end

100 101
    private

102 103 104 105 106 107 108 109 110 111 112 113
    def extract_piece(text)
      @extractions ||= {}

      md5 = Digest::MD5.hexdigest(text)
      @extractions[md5] = text
      "{gfm-extraction-#{md5}}"
    end

    def insert_piece(id)
      @extractions[id]
    end

114 115 116 117 118
    # Private: Parses text for references and emoji
    #
    # text - Text to parse
    #
    # Returns parsed text
skv's avatar
skv committed
119 120
    def parse(text, project = @project)
      parse_references(text, project) if project
121 122 123 124

      text
    end

125
    NAME_STR = '[a-zA-Z0-9_][a-zA-Z0-9_\-\.]*'
126 127
    PROJ_STR = "(?<project>#{NAME_STR}/#{NAME_STR})"

128
    REFERENCE_PATTERN = %r{
129 130
      (?<prefix>\W)?                         # Prefix
      (                                      # Reference
131
         @(?<user>#{NAME_STR})               # User name
Nikita Verkhovin's avatar
Nikita Verkhovin committed
132
        |~(?<label>\d+)                      # Label ID
133
        |(?<issue>([A-Z\-]+-)\d+)            # JIRA Issue ID
134 135
        |#{PROJ_STR}?\#(?<issue>([a-zA-Z\-]+-)?\d+) # Issue ID
        |#{PROJ_STR}?!(?<merge_request>\d+)  # MR ID
136
        |\$(?<snippet>\d+)                   # Snippet ID
137
        |(#{PROJ_STR}@)?(?<commit_range>[\h]{6,40}\.{2,3}[\h]{6,40}) # Commit range
138
        |(#{PROJ_STR}@)?(?<commit>[\h]{6,40}) # Commit ID
139
        |(?<skip>gfm-extraction-[\h]{6,40})  # Skip gfm extractions. Otherwise will be parsed as commit
140
      )
141
      (?<suffix>\W)?                         # Suffix
142 143
    }x.freeze

144
    TYPES = [:user, :issue, :label, :merge_request, :snippet, :commit, :commit_range].freeze
145

skv's avatar
skv committed
146
    def parse_references(text, project = @project)
147
      # parse reference links
148
      text.gsub!(REFERENCE_PATTERN) do |match|
149
        type       = TYPES.select{|t| !$~[t].nil?}.first
150

151 152 153 154 155 156
        actual_project = project
        project_prefix = nil
        project_path = $LAST_MATCH_INFO[:project]
        if project_path
          actual_project = ::Project.find_with_namespace(project_path)
          project_prefix = project_path
157
        end
158 159 160 161 162 163 164

        parse_result($LAST_MATCH_INFO, type,
                     actual_project, project_prefix) || match
      end
    end

    # Called from #parse_references.  Attempts to build a gitlab reference
Vinnie Okada's avatar
Vinnie Okada committed
165 166 167
    # link.  Returns nil if +type+ is nil, if the match string is an HTML
    # entity, if the reference is invalid, or if the matched text includes an
    # invalid project path.
168 169 170 171
    def parse_result(match_info, type, project, project_prefix)
      prefix = match_info[:prefix]
      suffix = match_info[:suffix]

Vinnie Okada's avatar
Vinnie Okada committed
172 173
      return nil if html_entity?(prefix, suffix) || type.nil?
      return nil if project.nil? && !project_prefix.nil?
174 175 176 177 178 179 180 181

      identifier = match_info[type]
      ref_link = reference_link(type, identifier, project, project_prefix)

      if ref_link
        "#{prefix}#{ref_link}#{suffix}"
      else
        nil
182 183
      end
    end
184

185 186 187 188 189 190
    # Return true if the +prefix+ and +suffix+ indicate that the matched string
    # is an HTML entity like &amp;
    def html_entity?(prefix, suffix)
      prefix && suffix && prefix[0] == '&' && suffix[-1] == ';'
    end

191 192 193 194 195 196
    # Private: Dispatches to a dedicated processing method based on reference
    #
    # reference  - Object reference ("@1234", "!567", etc.)
    # identifier - Object identifier (Issue ID, SHA hash, etc.)
    #
    # Returns string rendered by the processing method
197 198
    def reference_link(type, identifier, project = @project, prefix_text = nil)
      send("reference_#{type}", identifier, project, prefix_text)
199 200
    end

201
    def reference_user(identifier, project = @project, _ = nil)
202
      options = html_options.merge(
203 204
          class: "gfm gfm-team_member #{html_options[:class]}"
        )
205 206

      if identifier == "all"
Vinnie Okada's avatar
Vinnie Okada committed
207
        link_to("@all", namespace_project_url(project.namespace, project), options)
Douwe Maan's avatar
Douwe Maan committed
208 209 210 211 212 213 214 215 216
      elsif namespace = Namespace.find_by(path: identifier)
        url =
          if namespace.type == "Group"
            group_url(identifier)
          else 
            user_url(identifier)
          end
          
        link_to("@#{identifier}", url, options)
217 218 219
      end
    end

Nikita Verkhovin's avatar
Nikita Verkhovin committed
220 221 222 223 224 225 226
    def reference_label(identifier, project = @project, _ = nil)
      if label = project.labels.find_by(id: identifier)
        options = html_options.merge(
          class: "gfm gfm-label #{html_options[:class]}"
        )
        link_to(
          render_colored_label(label),
Vinnie Okada's avatar
Vinnie Okada committed
227
          namespace_project_issues_path(project.namespace, project, label_name: label.name),
Nikita Verkhovin's avatar
Nikita Verkhovin committed
228 229 230 231 232
          options
        )
      end
    end

233
    def reference_issue(identifier, project = @project, prefix_text = nil)
234
      if project.default_issues_tracker?
skv's avatar
skv committed
235 236
        if project.issue_exists? identifier
          url = url_for_issue(identifier, project)
237
          title = title_for_issue(identifier, project)
skv's avatar
skv committed
238 239 240 241
          options = html_options.merge(
            title: "Issue: #{title}",
            class: "gfm gfm-issue #{html_options[:class]}"
          )
242

243
          link_to("#{prefix_text}##{identifier}", url, options)
244
        end
245
      else
246 247
        if project.external_issue_tracker.present?
          reference_external_issue(identifier, project,
Vinnie Okada's avatar
Vinnie Okada committed
248
                                   prefix_text)
249
        end
250 251 252
      end
    end

253 254
    def reference_merge_request(identifier, project = @project,
                                prefix_text = nil)
skv's avatar
skv committed
255 256 257 258 259
      if merge_request = project.merge_requests.find_by(iid: identifier)
        options = html_options.merge(
          title: "Merge Request: #{merge_request.title}",
          class: "gfm gfm-merge_request #{html_options[:class]}"
        )
Vinnie Okada's avatar
Vinnie Okada committed
260 261
        url = namespace_project_merge_request_url(project.namespace, project,
                                                  merge_request)
262
        link_to("#{prefix_text}!#{identifier}", url, options)
263 264 265
      end
    end

266
    def reference_snippet(identifier, project = @project, _ = nil)
skv's avatar
skv committed
267 268 269 270 271
      if snippet = project.snippets.find_by(id: identifier)
        options = html_options.merge(
          title: "Snippet: #{snippet.title}",
          class: "gfm gfm-snippet #{html_options[:class]}"
        )
Vinnie Okada's avatar
Vinnie Okada committed
272 273 274 275 276
        link_to(
          "$#{identifier}",
          namespace_project_snippet_url(project.namespace, project, snippet),
          options
        )
277 278 279
      end
    end

280
    def reference_commit(identifier, project = @project, prefix_text = nil)
skv's avatar
skv committed
281 282 283 284 285
      if project.valid_repo? && commit = project.repository.commit(identifier)
        options = html_options.merge(
          title: commit.link_title,
          class: "gfm gfm-commit #{html_options[:class]}"
        )
Vinnie Okada's avatar
Vinnie Okada committed
286
        prefix_text = "#{prefix_text}@" if prefix_text
287 288
        link_to(
          "#{prefix_text}#{identifier}",
Vinnie Okada's avatar
Vinnie Okada committed
289
          namespace_project_commit_url(project.namespace, project, commit),
290 291
          options
        )
292 293
      end
    end
294

295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318
    def reference_commit_range(identifier, project = @project, prefix_text = nil)
      from_id, to_id = identifier.split(/\.{2,3}/, 2)

      inclusive = identifier !~ /\.{3}/
      from_id << "^" if inclusive

      if project.valid_repo? && 
          from = project.repository.commit(from_id) && 
          to = project.repository.commit(to_id)

        options = html_options.merge(
          title: "Commits #{from_id} through #{to_id}",
          class: "gfm gfm-commit_range #{html_options[:class]}"
        )
        prefix_text = "#{prefix_text}@" if prefix_text

        link_to(
          "#{prefix_text}#{identifier}",
          namespace_project_compare_url(project.namespace, project, from: from_id, to: to_id),
          options
        )
      end
    end

319
    def reference_external_issue(identifier, project = @project,
Vinnie Okada's avatar
Vinnie Okada committed
320
                                 prefix_text = nil)
Andrew Kumanyaev's avatar
Andrew Kumanyaev committed
321
      url = url_for_issue(identifier, project)
322
      title = project.external_issue_tracker.title
323

skv's avatar
skv committed
324 325 326 327
      options = html_options.merge(
        title: "Issue in #{title}",
        class: "gfm gfm-issue #{html_options[:class]}"
      )
Vinnie Okada's avatar
Vinnie Okada committed
328
      link_to("#{prefix_text}##{identifier}", url, options)
329
    end
330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348

    # Turn list items that start with "[ ]" into HTML checkbox inputs.
    def parse_tasks(text)
      li_tag = '<li class="task-list-item">'
      unchecked_box = '<input type="checkbox" value="on" disabled />'
      checked_box = unchecked_box.sub(/\/>$/, 'checked="checked" />')

      # Regexp captures don't seem to work when +text+ is an
      # ActiveSupport::SafeBuffer, hence the `String.new`
      String.new(text).gsub(Taskable::TASK_PATTERN_HTML) do
        checked = $LAST_MATCH_INFO[:checked].downcase == 'x'

        if checked
          "#{li_tag}#{checked_box}"
        else
          "#{li_tag}#{unchecked_box}"
        end
      end
    end
349 350
  end
end