markdown.rb 5.98 KB
Newer Older
1
module Gitlab
2
  # Custom parser for GitLab-flavored Markdown
3
  #
4
  # It replaces references in the text with links to the appropriate items in
5
  # GitLab.
6 7 8 9 10 11 12
  #
  # Supported reference formats are:
  #   * @foo for team members
  #   * #123 for issues
  #   * !123 for merge requests
  #   * $123 for snippets
  #   * 123456 for commits
13
  #
14 15
  # It also parses Emoji codes to insert images. See
  # http://www.emoji-cheat-sheet.com/ for a list of the supported icons.
16
  #
17
  # Examples
18
  #
19
  #   >> gfm("Hey @david, can you fix this?")
20 21
  #   => "Hey <a href="/gitlab/team_members/1">@david</a>, can you fix this?"
  #
22
  #   >> gfm("Commit 35d5f7c closes #1234")
23
  #   => "Commit <a href="/gitlab/commits/35d5f7c">35d5f7c</a> closes <a href="/gitlab/issues/1234">#1234</a>"
24 25 26 27
  #
  #   >> gfm(":trollface:")
  #   => "<img alt=\":trollface:\" class=\"emoji\" src=\"/images/trollface.png" title=\":trollface:\" />
  module Markdown
28
    REFERENCE_PATTERN = %r{
29 30 31 32 33 34 35
      (?<prefix>\W)?                         # Prefix
      (                                      # Reference
         @(?<user>[a-zA-Z][a-zA-Z0-9_\-\.]*) # User name
        |\#(?<issue>\d+)                     # Issue ID
        |!(?<merge_request>\d+)              # MR ID
        |\$(?<snippet>\d+)                   # Snippet ID
        |(?<commit>[\h]{6,40})               # Commit ID
36
      )
37
      (?<suffix>\W)?                         # Suffix
38 39
    }x.freeze

40 41
    TYPES = [:user, :issue, :merge_request, :snippet, :commit].freeze

Robert Speicher's avatar
Robert Speicher committed
42
    EMOJI_PATTERN = %r{(:(\S+):)}.freeze
43

44 45
    attr_reader :html_options

46 47 48 49 50 51 52 53 54
    # Public: Parse the provided text with GitLab-Flavored Markdown
    #
    # text         - the source text
    # html_options - extra options for the reference links as given to link_to
    #
    # Note: reference links will only be generated if @project is set
    def gfm(text, html_options = {})
      return text if text.nil?

55 56 57 58
      # 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
59

60
      @html_options = html_options
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80

      # Extract pre blocks so they are not altered
      # from http://github.github.com/github-flavored-markdown/
      extractions = {}
      text.gsub!(%r{<pre>.*?</pre>|<code>.*?</code>}m) do |match|
        md5 = Digest::MD5.hexdigest(match)
        extractions[md5] = match
        "{gfm-extraction-#{md5}}"
      end

      # TODO: add popups with additional information

      text = parse(text)

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

      sanitize text.html_safe, attributes: ActionView::Base.sanitized_allowed_attributes + %w(id class)
81 82
    end

83 84 85 86 87 88
    private

    # Private: Parses text for references and emoji
    #
    # text - Text to parse
    #
89 90
    # Note: reference links will only be generated if @project is set
    #
91
    # Returns parsed text
92
    def parse(text)
93 94 95 96 97 98 99
      parse_references(text) if @project
      parse_emoji(text)

      text
    end

    def parse_references(text)
100
      # parse reference links
101
      text.gsub!(REFERENCE_PATTERN) do |match|
102 103 104 105
        prefix     = $~[:prefix]
        suffix     = $~[:suffix]
        type       = TYPES.select{|t| !$~[t].nil?}.first
        identifier = $~[type]
106

107
        # Avoid HTML entities
108
        if prefix && suffix && prefix[0] == '&' && suffix[-1] == ';'
109
          match
110 111
        elsif ref_link = reference_link(type, identifier)
          "#{prefix}#{ref_link}#{suffix}"
112 113 114
        else
          match
        end
115 116
      end
    end
117

118
    def parse_emoji(text)
119
      # parse emoji
120
      text.gsub!(EMOJI_PATTERN) do |match|
121
        if valid_emoji?($2)
randx's avatar
randx committed
122
          image_tag("emoji/#{$2}.png", size: "20x20", class: 'emoji', title: $1, alt: $1)
123 124 125 126
        else
          match
        end
      end
127 128
    end

129 130 131 132 133 134
    # Private: Checks if an emoji icon exists in the image asset directory
    #
    # emoji - Identifier of the emoji as a string (e.g., "+1", "heart")
    #
    # Returns boolean
    def valid_emoji?(emoji)
Riyad Preukschas's avatar
Riyad Preukschas committed
135
      Emoji.names.include? emoji
136
    end
137 138 139 140 141 142 143

    # 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
144 145
    def reference_link(type, identifier)
      send("reference_#{type}", identifier)
146 147 148
    end

    def reference_user(identifier)
149
      if member = @project.users_projects.joins(:user).where(users: { username: identifier }).first
150
        link_to("@#{identifier}", project_team_member_path(@project, member), html_options.merge(class: "gfm gfm-team_member #{html_options[:class]}")) if member
151 152 153 154 155
      end
    end

    def reference_issue(identifier)
      if issue = @project.issues.where(id: identifier).first
156
        link_to("##{identifier}", project_issue_path(@project, issue), html_options.merge(title: "Issue: #{issue.title}", class: "gfm gfm-issue #{html_options[:class]}"))
157 158 159 160 161
      end
    end

    def reference_merge_request(identifier)
      if merge_request = @project.merge_requests.where(id: identifier).first
162
        link_to("!#{identifier}", project_merge_request_path(@project, merge_request), html_options.merge(title: "Merge Request: #{merge_request.title}", class: "gfm gfm-merge_request #{html_options[:class]}"))
163 164 165 166 167
      end
    end

    def reference_snippet(identifier)
      if snippet = @project.snippets.where(id: identifier).first
168
        link_to("$#{identifier}", project_snippet_path(@project, snippet), html_options.merge(title: "Snippet: #{snippet.title}", class: "gfm gfm-snippet #{html_options[:class]}"))
169 170 171 172
      end
    end

    def reference_commit(identifier)
173
      if @project.valid_repo? && commit = @project.commit(identifier)
174
        link_to(identifier, project_commit_path(@project, commit), html_options.merge(title: CommitDecorator.new(commit).link_title, class: "gfm gfm-commit #{html_options[:class]}"))
175 176 177 178
      end
    end
  end
end