summaryrefslogtreecommitdiff
path: root/lib/banzai/filter/reference_filter.rb
blob: 132f0a4bd93fab26b9529b68eefcf23e5ee937fa (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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
require 'active_support/core_ext/string/output_safety'
require 'html/pipeline/filter'

module Banzai
  module Filter
    # Base class for GitLab Flavored Markdown reference filters.
    #
    # References within <pre>, <code>, <a>, and <style> elements are ignored.
    #
    # Context options:
    #   :project (required) - Current project, ignored if reference is cross-project.
    #   :only_path          - Generate path-only links.
    class ReferenceFilter < HTML::Pipeline::Filter
      def self.user_can_see_reference?(user, node, context)
        if node.has_attribute?('data-project')
          project_id = node.attr('data-project').to_i
          return true if project_id == context[:project].try(:id)

          project = Project.find(project_id) rescue nil
          Ability.abilities.allowed?(user, :read_project, project)
        else
          true
        end
      end

      def self.user_can_reference?(user, node, context)
        true
      end

      def self.referenced_by(node)
        raise NotImplementedError, "#{self} does not implement #{__method__}"
      end

      # Returns a data attribute String to attach to a reference link
      #
      # attributes - Hash, where the key becomes the data attribute name and the
      #              value is the data attribute value
      #
      # Examples:
      #
      #   data_attribute(project: 1, issue: 2)
      #   # => "data-reference-filter=\"SomeReferenceFilter\" data-project=\"1\" data-issue=\"2\""
      #
      #   data_attribute(project: 3, merge_request: 4)
      #   # => "data-reference-filter=\"SomeReferenceFilter\" data-project=\"3\" data-merge-request=\"4\""
      #
      # Returns a String
      def data_attribute(attributes = {})
        attributes[:reference_filter] = self.class.name.demodulize
        attributes.delete(:original) if context[:no_original_data]
        attributes.map { |key, value| %Q(data-#{key.to_s.dasherize}="#{escape_once(value)}") }.join(" ")
      end

      def escape_once(html)
        html.html_safe? ? html : ERB::Util.html_escape_once(html)
      end

      def ignore_parents
        @ignore_parents ||= begin
          # Don't look for references in text nodes that are children of these
          # elements.
          parents = %w(pre code a style)
          parents << 'blockquote' if context[:ignore_blockquotes]
          parents.to_set
        end
      end

      def ignored_ancestry?(node)
        has_ancestor?(node, ignore_parents)
      end

      def project
        context[:project]
      end

      def reference_class(type)
        "gfm gfm-#{type}"
      end

      # Iterate through the document's text nodes, yielding the current node's
      # content if:
      #
      # * The `project` context value is present AND
      # * The node's content matches `pattern` AND
      # * The node is not an ancestor of an ignored node type
      #
      # pattern - Regex pattern against which to match the node's content
      #
      # Yields the current node's String contents. The result of the block will
      # replace the node's existing content and update the current document.
      #
      # Returns the updated Nokogiri::HTML::DocumentFragment object.
      def replace_text_nodes_matching(pattern)
        return doc if project.nil?

        search_text_nodes(doc).each do |node|
          next if ignored_ancestry?(node)
          next unless node.text =~ pattern

          content = node.to_html

          html = yield content

          next if html == content

          node.replace(html)
        end

        doc
      end

      # Iterate through the document's link nodes, yielding the current node's
      # content if:
      #
      # * The `project` context value is present AND
      # * The node's content matches `pattern`
      #
      # pattern - Regex pattern against which to match the node's content
      #
      # Yields the current node's String contents. The result of the block will
      # replace the node and update the current document.
      #
      # Returns the updated Nokogiri::HTML::DocumentFragment object.
      def replace_link_nodes_with_text(pattern)
        return doc if project.nil?

        doc.xpath('descendant-or-self::a').each do |node|
          klass = node.attr('class')
          next if klass && klass.include?('gfm')

          link = node.attr('href')
          text = node.text

          next unless link && text

          link = CGI.unescape(link)
          next unless link.force_encoding('UTF-8').valid_encoding?
          # Ignore ending punctionation like periods or commas
          next unless link == text && text =~ /\A#{pattern}/

          html = yield text

          next if html == text

          node.replace(html)
        end

        doc
      end

      # Iterate through the document's link nodes, yielding the current node's
      # content if:
      #
      # * The `project` context value is present AND
      # * The node's HREF matches `pattern`
      #
      # pattern - Regex pattern against which to match the node's HREF
      #
      # Yields the current node's String HREF and String content.
      # The result of the block will replace the node and update the current document.
      #
      # Returns the updated Nokogiri::HTML::DocumentFragment object.
      def replace_link_nodes_with_href(pattern)
        return doc if project.nil?

        doc.xpath('descendant-or-self::a').each do |node|
          klass = node.attr('class')
          next if klass && klass.include?('gfm')

          link = node.attr('href')
          text = node.text

          next unless link && text
          link = CGI.unescape(link)
          next unless link.force_encoding('UTF-8').valid_encoding?
          next unless link && link =~ /\A#{pattern}\z/

          html = yield link, text

          next if html == link

          node.replace(html)
        end

        doc
      end

      # Ensure that a :project key exists in context
      #
      # Note that while the key might exist, its value could be nil!
      def validate
        needs :project
      end
    end
  end
end