summaryrefslogtreecommitdiff
path: root/lib/gitlab/markdown.rb
blob: ba993abf7f72fdf153b000e82867868c65d5845d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
module Gitlab
  # Custom parser for GitLab-flavored Markdown
  #
  # It replaces references in the text with links to the appropriate items in
  # GitLab.
  #
  # Supported reference formats are:
  #   * @foo for team members
  #   * #123 for issues
  #   * !123 for merge requests
  #   * $123 for snippets
  #   * 123456 for commits
  #
  # It also parses Emoji codes to insert images. See
  # http://www.emoji-cheat-sheet.com/ for a list of the supported icons.
  #
  # Examples
  #
  #   >> gfm("Hey @david, can you fix this?")
  #   => "Hey <a href="/gitlab/team_members/1">@david</a>, can you fix this?"
  #
  #   >> gfm("Commit 35d5f7c closes #1234")
  #   => "Commit <a href="/gitlab/commits/35d5f7c">35d5f7c</a> closes <a href="/gitlab/issues/1234">#1234</a>"
  #
  #   >> gfm(":trollface:")
  #   => "<img alt=\":trollface:\" class=\"emoji\" src=\"/images/trollface.png" title=\":trollface:\" />
  module Markdown
    REFERENCE_PATTERN = %r{
      (\W)?           # Prefix (1)
      (               # Reference (2)
        @([\w\._]+)   # User name (3)
        |[#!$](\d+)   # Issue/MR/Snippet ID (4)
        |([\h]{6,40}) # Commit ID (5)
      )
      (\W)?           # Suffix (6)
    }x.freeze

    EMOJI_PATTERN = %r{(:(\S+):)}.freeze

    attr_reader :html_options

    # 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?

      # 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

      @html_options = html_options

      # 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)
    end

    private

    # Private: Parses text for references and emoji
    #
    # text - Text to parse
    #
    # Note: reference links will only be generated if @project is set
    #
    # Returns parsed text
    def parse(text)
      parse_references(text) if @project
      parse_emoji(text)

      text
    end

    def parse_references(text)
      # parse reference links
      text.gsub!(REFERENCE_PATTERN) do |match|
        prefix     = $1 || ''
        reference  = $2
        identifier = $3 || $4 || $5
        suffix     = $6 || ''

        # Avoid HTML entities
        if prefix.ends_with?('&') || suffix.starts_with?(';')
          match
        elsif ref_link = reference_link(reference, identifier)
          prefix + ref_link + suffix
        else
          match
        end
      end
    end

    def parse_emoji(text)
      # parse emoji
      text.gsub!(EMOJI_PATTERN) do |match|
        if valid_emoji?($2)
          image_tag("emoji/#{$2}.png", size: "20x20", class: 'emoji', title: $1, alt: $1)
        else
          match
        end
      end
    end

    # 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)
      Emoji::NAMES.include? emoji
    end

    # 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
    def reference_link(reference, identifier)
      case reference
      when /^@/  then reference_user(identifier)
      when /^#/  then reference_issue(identifier)
      when /^!/  then reference_merge_request(identifier)
      when /^\$/ then reference_snippet(identifier)
      when /^\h/ then reference_commit(identifier)
      end
    end

    def reference_user(identifier)
      if user = @project.users.where(name: identifier).first
        member = @project.users_projects.where(user_id: user).first
        link_to("@#{identifier}", project_team_member_path(@project, member), html_options.merge(class: "gfm gfm-team_member #{html_options[:class]}")) if member
      end
    end

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

    def reference_merge_request(identifier)
      if merge_request = @project.merge_requests.where(id: identifier).first
        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]}"))
      end
    end

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

    def reference_commit(identifier)
      if commit = @project.commit(identifier)
        link_to(identifier, project_commit_path(@project, commit), html_options.merge(title: CommitDecorator.new(commit).link_title, class: "gfm gfm-commit #{html_options[:class]}"))
      end
    end
  end
end