Class: Banzai::Filter::TaskListFilter

Inherits:
HTML::Pipeline::Filter
  • Object
show all
Includes:
Concerns::PipelineTimingCheck
Defined in:
lib/banzai/filter/task_list_filter.rb

Overview

TaskListFilter annotates task list items with aria-labels, adds strikethroughs to the text body of inapplicable items (created with ‘[~]`).

This should be run on the HTML generated by the Markdown filter, which handles the actual parsing, after the SanitizationFilter.

Constant Summary collapse

CSS =
'input.task-list-item-checkbox'
XPATH =
Gitlab::Utils::Nokogiri.css_to_xpath(CSS).freeze

Constants included from Concerns::PipelineTimingCheck

Concerns::PipelineTimingCheck::MAX_PIPELINE_SECONDS

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Concerns::PipelineTimingCheck

#exceeded_pipeline_max?

Class Method Details

.text_for_task_item_from_input(input) ⇒ Object

Gets the text for the task list item, given the <input> checkbox that declares it.



51
52
53
54
55
56
57
# File 'lib/banzai/filter/task_list_filter.rb', line 51

def text_for_task_item_from_input(input)
  text_content = +''
  yield_next_siblings_until(input, %w[ol ul]) do |el|
    text_content << el.text
  end
  text_content.strip
end

.text_html_for_task_item_from_input(input) ⇒ Object

Gets the HTML corresponding to the task list item text, given the <input> checkbox that declares it. This should be used for task list item matching only.



61
62
63
64
65
66
67
# File 'lib/banzai/filter/task_list_filter.rb', line 61

def text_html_for_task_item_from_input(input)
  html_content = +''
  yield_next_siblings_until(input, %w[ol ul]) do |el|
    html_content << el.to_html
  end
  html_content
end

.yield_next_siblings_until(start, els) ⇒ Object

Yields the #next_sibling of start, and then the #next_sibling of that, until either there are no more next siblings or a matching element is encountered.

The following #next_sibling is evaluated before each element is yielded, so they can safely be reparented or removed without affecting iteration.



74
75
76
77
78
79
80
81
# File 'lib/banzai/filter/task_list_filter.rb', line 74

def yield_next_siblings_until(start, els)
  it = start.next_sibling
  while it && els.exclude?(it.name)
    following = it.next_sibling
    yield it
    it = following
  end
end

.yield_text_nodes_without_descending_into(start, els) ⇒ Object

Starting from start, iteratively yield text nodes contained within its children, and its (repeated) #next_siblings and their children, not descending into any of the elements given by els.

The following #next_sibling is evaluated before yielding, as above.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/banzai/filter/task_list_filter.rb', line 88

def yield_text_nodes_without_descending_into(start, els)
  stack = [start]
  while stack.any?
    it = stack.pop

    stack << it.next_sibling if it.next_sibling

    if it.text?
      yield it unless it.content.blank?
    elsif els.exclude?(it.name)
      stack.concat(it.children.reverse)
    end
  end
end

Instance Method Details

#callObject



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
# File 'lib/banzai/filter/task_list_filter.rb', line 16

def call
  doc.xpath(XPATH).each do |node|
    text_content = self.class.text_for_task_item_from_input(node)
    truncated_text_content = text_content.truncate(100, separator: ' ', omission: '')
    node['aria-label'] = format(_('Check option: %{option}'), option: truncated_text_content)

    next unless node.has_attribute?('data-inapplicable')

    # We manually apply a <s> to strikethrough text in inapplicable task items,
    # specifically in tight lists where text within the list items isn't contained in a paragraph.
    # (Those are handled entirely by styles.)
    #
    # To handle tight lists, we wrap every text node after the checkbox in <s>, not descending
    # into <p> or <div> (as they're indicative of non-tight lists) or <ul> or <ol> (as we
    # explicitly want to avoid strikethrough styles on sublists, which may have applicable
    # task items!).

    # This is awkward, but we need to include a text node with a space after the input.
    # Otherwise, the strikethrough will start *immediately* next to the <input>, because
    # the first next sibling of the input is always a text node that starts with a space!
    space = node.add_next_sibling(node.document.create_text_node(' '))

    inapplicable_s = node.document.create_element('s')
    inapplicable_s['class'] = 'inapplicable'

    self.class.yield_text_nodes_without_descending_into(space.next_sibling, %w[p div ul ol]) do |el|
      el.wrap(inapplicable_s)
    end
  end

  doc
end