Module: Banzai::Renderer

Defined in:
lib/banzai/renderer.rb

Constant Summary collapse

USER_CONTENT_ID_PREFIX =
'user-content-'
HTML_PIPELINE_SUBSCRIPTION =
'call_filter.html_pipeline'

Class Method Summary collapse

Class Method Details

.cache_collection_render(texts_and_contexts) ⇒ Object

Perform multiple render from an Array of Markdown String into an Array of HTML-safe String of HTML.

The redis cache is completely obviated if we receive a ‘:rendered` key in the context, as it is assumed the item has been pre-rendered somewhere else and there is no need to cache it.

If no ‘:rendered` key is present in the context, as the rendered Markdown String can be already cached, read all the data from the cache using Rails.cache.read_multi operation. If the Markdown String is not in the cache or it’s not cacheable (no cache_key entry is provided in the context) the Markdown String is rendered and stored in the cache so the next render call gets the rendered HTML-safe String from the cache.

For further explanation see #render method comments.

texts_and_contexts - An Array of Hashes that contains the Markdown String (:text)

an options passed to our HTML Pipeline (:context)

If on the :context you specify a :cache_key entry will be used to retrieve it and cache the result of rendering the Markdown String.

Returns an Array containing HTML-safe String instances.

Example:

texts_and_contexts
=> [{ text: '### Hello',
      context: { cache_key: [note, :note] } }]


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
# File 'lib/banzai/renderer.rb', line 84

def self.cache_collection_render(texts_and_contexts)
  items_collection = texts_and_contexts.each do |item|
    context = item[:context]

    if context.key?(:rendered)
      item[:rendered] = context.delete(:rendered)
    else
      # If the attribute didn't come in pre-rendered, let's prepare it for caching it in redis
      cache_key = full_cache_multi_key(context.delete(:cache_key), context[:pipeline])
      item[:cache_key] = cache_key if cache_key
    end
  end

  cacheable_items, non_cacheable_items = items_collection.group_by do |item|
    if item.key?(:rendered)
      # We're not really doing anything here as these don't need any processing, but leaving it just in case
      # as they could have a cache_key and we don't want them to be re-rendered
      :rendered
    elsif item.key?(:cache_key)
      :cacheable
    else
      :non_cacheable
    end
  end.values_at(:cacheable, :non_cacheable)

  items_in_cache = []
  items_not_in_cache = []

  if cacheable_items.present?
    items_in_cache = Rails.cache.read_multi(*cacheable_items.map { |item| item[:cache_key] })
    items_not_in_cache = cacheable_items.reject do |item|
      item[:rendered] = items_in_cache[item[:cache_key]]
      items_in_cache.key?(item[:cache_key])
    end
  end

  (items_not_in_cache + Array.wrap(non_cacheable_items)).each do |item|
    item[:rendered] = render(item[:text], item[:context])
    Rails.cache.write(item[:cache_key], item[:rendered]) if item[:cache_key]
  end

  items_collection.map { |item| item[:rendered] }
end

.cacheless_render(text, context = {}) ⇒ Object



162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/banzai/renderer.rb', line 162

def self.cacheless_render(text, context = {})
  return text.to_s unless text.present?

  result = render_result(text, context)

  output = result[:output]
  if output.respond_to?(:to_html)
    output.to_html
  else
    output.to_s
  end
end

.cacheless_render_field(object, field, context = {}) ⇒ Object

Same as render_field, but without consulting or updating the cache field



49
50
51
52
53
54
# File 'lib/banzai/renderer.rb', line 49

def self.cacheless_render_field(object, field, context = {})
  text = object.__send__(field) # rubocop:disable GitlabSecurity/PublicSend
  context = context.reverse_merge(object.banzai_render_context(field)) if object.respond_to?(:banzai_render_context)

  cacheless_render(text, context)
end

.color_for_duration(duration, min: 1, max: 2) ⇒ Object



231
232
233
234
235
236
237
238
239
# File 'lib/banzai/renderer.rb', line 231

def self.color_for_duration(duration, min: 1, max: 2)
  if duration < min
    :green
  elsif duration >= min && duration < max
    :orange
  else
    :red
  end
end

.formatted_duration(duration) ⇒ Object



226
227
228
229
# File 'lib/banzai/renderer.rb', line 226

def self.formatted_duration(duration)
  color = color_for_duration(duration)
  Rainbow.new.wrap(format('%5f_s', duration)).color(color)
end

.full_cache_key(cache_key, pipeline_name) ⇒ Object



175
176
177
178
179
180
181
182
183
# File 'lib/banzai/renderer.rb', line 175

def self.full_cache_key(cache_key, pipeline_name)
  return unless cache_key

  [
    "banzai",
    *cache_key, pipeline_name || :full,
    Gitlab::MarkdownCache.latest_cached_markdown_version(local_version: nil)
  ]
end

.full_cache_multi_key(cache_key, pipeline_name) ⇒ Object

To map Rails.cache.read_multi results we need to know the Rails.cache.expanded_key. Other option will be to generate stringified keys on our side and don’t delegate to Rails.cache.expanded_key method.



188
189
190
191
192
# File 'lib/banzai/renderer.rb', line 188

def self.full_cache_multi_key(cache_key, pipeline_name)
  return unless cache_key

  Rails.cache.__send__(:expanded_key, full_cache_key(cache_key, pipeline_name)) # rubocop:disable GitlabSecurity/PublicSend
end

.instrument_filtersObject

this is built specifically for outputting debug timing/information for the Banzai pipeline. Example usage:

Banzai.render(markdown, project: nil, debug_timing: true)
Banzai.render(markdown, project: Project.first, debug: true)


198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
# File 'lib/banzai/renderer.rb', line 198

def self.instrument_filters
  service = ActiveSupport::Notifications
  HTML::Pipeline.default_instrumentation_service = service

  service.monotonic_subscribe(HTML_PIPELINE_SUBSCRIPTION) do |_event, start, ending, _transaction_id, payload|
    duration = ending - start
    payload[:result][:pipeline_timing] = payload[:result][:pipeline_timing].to_f + duration

    if payload[:context][:debug] || payload[:context][:debug_timing]
      duration_str = formatted_duration(duration)
      pipeline_timing_str = formatted_duration(payload[:result][:pipeline_timing])
      filter_name = payload[:filter].delete_prefix('Banzai::Filter::')
      pipeline_name = payload[:pipeline].delete_prefix('Banzai::Pipeline::')

      logger = Logger.new($stdout)
      logger.debug "#{duration_str} (#{pipeline_timing_str}): #{filter_name} [#{pipeline_name}]"

      if payload[:context][:debug]
        logger.debug(payload)
      end
    end
  end

  yield
ensure
  service.unsubscribe(HTML_PIPELINE_SUBSCRIPTION) if service
end

.post_process(html, context) ⇒ Object

Perform post-processing on an HTML String

This method is used to perform state-dependent changes to a String of HTML, such as removing references that the current user doesn’t have permission to make (‘ReferenceRedactorFilter`).

html - String to process context - Hash of options to customize output

:pipeline  - Symbol pipeline type - for context transform only, defaults to :full
:project   - Project
:user      - User object
:post_process_pipeline - pipeline to use for post_processing - defaults to PostProcessPipeline

Returns an HTML-safe String



149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/banzai/renderer.rb', line 149

def self.post_process(html, context)
  context = Pipeline[context[:pipeline]].transform_context(context)

  # Use a passed class for the pipeline or default to PostProcessPipeline
  pipeline = context.delete(:post_process_pipeline) || ::Banzai::Pipeline::PostProcessPipeline

  if context[:xhtml]
    pipeline.to_document(html, context).to_html(save_with: Nokogiri::XML::Node::SaveOptions::AS_XHTML)
  else
    pipeline.to_html(html, context)
  end.html_safe
end

.render(text, context = {}) ⇒ Object

Convert a Markdown String into an HTML-safe String of HTML

Note that while the returned HTML will have been sanitized of dangerous HTML, it may post a risk of information leakage if it’s not also passed through ‘post_process`.

Also note that the returned String is always HTML, not XHTML. Views requiring XHTML, such as Atom feeds, need to call ‘post_process` on the result, providing the appropriate `pipeline` option.

text - Markdown String context - Hash of context options passed to our HTML Pipeline

Returns an HTML-safe String



22
23
24
25
26
27
28
29
30
31
32
33
# File 'lib/banzai/renderer.rb', line 22

def self.render(text, context = {})
  cache_key = context.delete(:cache_key)
  cache_key = full_cache_key(cache_key, context[:pipeline])

  if cache_key
    Rails.cache.fetch(cache_key) do
      cacheless_render(text, context)
    end
  else
    cacheless_render(text, context)
  end
end

.render_field(object, field, context = {}) ⇒ Object

Convert a Markdown-containing field on an object into an HTML-safe String of HTML. This method is analogous to calling render(object.field), but it can cache the rendered HTML in the object, rather than Redis.



38
39
40
41
42
43
44
45
46
# File 'lib/banzai/renderer.rb', line 38

def self.render_field(object, field, context = {})
  unless object.respond_to?(:cached_markdown_fields)
    return cacheless_render_field(object, field, context)
  end

  object.refresh_markdown_cache! unless object.cached_html_up_to_date?(field)

  object.cached_html_for(field)
end

.render_result(text, context = {}) ⇒ Object



128
129
130
131
132
133
# File 'lib/banzai/renderer.rb', line 128

def self.render_result(text, context = {})
  instrument_filters do
    text = Pipeline[:pre_process].to_html(text, context) if text
    Pipeline[context[:pipeline]].call(text, context)
  end
end