Skip to content
This repository was archived by the owner on Nov 1, 2017. It is now read-only.

Commit 618722f

Browse files
committed
Add TaskList base, Filter, Updatable with tests
1 parent 13d6067 commit 618722f

File tree

7 files changed

+616
-0
lines changed

7 files changed

+616
-0
lines changed

lib/task_list.rb

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
require 'task_list/version'
2+
3+
class TaskList
4+
attr_reader :source
5+
6+
Incomplete = "[ ]".freeze
7+
Complete = "[x]".freeze
8+
9+
# Pattern used to identify all task list items.
10+
# Useful when you need iterate over all items.
11+
ItemPattern = /
12+
^
13+
(?:\s*[-+*]|(?:\d+\.))? # optional list prefix
14+
\s* # optional whitespace prefix
15+
( # checkbox
16+
#{Regexp.escape(Complete)}|
17+
#{Regexp.escape(Incomplete)}
18+
)
19+
(?=\s) # followed by whitespace
20+
/x
21+
22+
# Used to filter out code fences from the source for comparison only.
23+
# http://rubular.com/r/x5EwZVrloI
24+
CodeFencesPattern = /
25+
^`{3} # ```
26+
(?:\s*\w+)?$ # followed by optional language
27+
.+? # code
28+
^`{3}$ # ```
29+
/xm
30+
31+
# Used to filter out potential mismatches (items not in lists).
32+
# http://rubular.com/r/OInl6CiePy
33+
ItemsInParasPattern = /
34+
^
35+
(
36+
#{Regexp.escape(Complete)}|
37+
#{Regexp.escape(Incomplete)}
38+
)
39+
.+
40+
$
41+
/x
42+
43+
# `source` is the Markdown source text with task list items following the
44+
# syntax as follows:
45+
#
46+
# - [ ] a task list item
47+
# - [ ] another item
48+
# - [x] a completed item
49+
#
50+
def initialize(source)
51+
@source = source || ''
52+
end
53+
54+
# Internal: the source cleaned of any potential false matches.
55+
def cleaned_source
56+
@cleaned_source ||= source.
57+
gsub("\r", '').
58+
gsub(CodeFencesPattern, '').
59+
gsub(ItemsInParasPattern, '')
60+
end
61+
62+
# Public: return the TaskList::Summary for this task list.
63+
#
64+
# Returns a TaskList::Summary.
65+
def summary
66+
@summary ||= TaskList::Summary.new(self)
67+
end
68+
69+
# Public: The TaskList::Item objects for the source text.
70+
#
71+
# FIXME: not very dry with #update.
72+
#
73+
# Returns an Array of TaskList::Item objects.
74+
def items
75+
@items ||=
76+
begin
77+
items = []
78+
lines = source.split("\n")
79+
clean = cleaned_source.split("\n")
80+
81+
id = 0
82+
lines.each do |line|
83+
if match = item?(line) and clean.include?(line.chomp)
84+
id += 1
85+
items << Item.new(id, match, line)
86+
end
87+
end
88+
89+
items
90+
end
91+
end
92+
93+
# Public: updates the task list item's checked state in the source.
94+
#
95+
# The source is the original Markdown text where the task list item was
96+
# identified.
97+
#
98+
# Returns a new TaskList with the updated source.
99+
def update(item_id, checked)
100+
item_id = Integer(item_id)
101+
result = source.split("\n")
102+
clean = cleaned_source.split("\n")
103+
104+
id = 0
105+
result.map! do |line|
106+
if item?(line) && clean.include?(line.chomp)
107+
id += 1
108+
# the item to update
109+
if item_id == id
110+
if checked
111+
# swap incomplete for complete
112+
line.sub! Incomplete, Complete
113+
else
114+
# and vice versa
115+
line.sub! Complete, Incomplete
116+
end
117+
end
118+
end
119+
line
120+
end
121+
122+
self.class.new(result.join("\n"))
123+
end
124+
125+
# Internal: shorthand for TaskList.item?
126+
def item?(text)
127+
self.class.item?(text)
128+
end
129+
130+
# Public: analyze the text to determine if it is a task list item.
131+
#
132+
# Returns the matched item String, nil otherwise.
133+
def self.item?(text)
134+
text.chomp =~ ItemPattern && $1
135+
end
136+
137+
class Item < Struct.new(:index, :checkbox_text, :source)
138+
def complete?
139+
checkbox_text == Complete
140+
end
141+
end
142+
143+
class Summary < Struct.new(:task_list)
144+
# Internal: just use TaskList#items.
145+
def items
146+
task_list.items
147+
end
148+
149+
# Public: returns true if there are any TaskList::Item objects.
150+
def items?
151+
item_count > 0
152+
end
153+
154+
# Public: returns the number of TaskList::Item objects.
155+
def item_count
156+
items.size
157+
end
158+
159+
# Public: returns the number of complete TaskList::Item objects.
160+
def complete_count
161+
items.select{ |i| i.complete? }.size
162+
end
163+
164+
# Public: returns the number of incomplete TaskList::Item objects.
165+
def incomplete_count
166+
items.select{ |i| !i.complete? }.size
167+
end
168+
end
169+
end

lib/task_list/filter.rb

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# encoding: utf-8
2+
require 'html/pipeline'
3+
4+
class TaskList
5+
# Returns a `Nokogiri::DocumentFragment` object.
6+
def self.filter(*args)
7+
Filter.call(*args)
8+
end
9+
10+
# TaskList filter replaces task list item markers (`[ ]` and `[x]`) with
11+
# checkboxes, marked up with metadata and behavior.
12+
#
13+
# This should be run on the HTML generated by the Markdown filter, after the
14+
# SanitizationFilter.
15+
#
16+
# Syntax
17+
# ------
18+
#
19+
# Task list items must be in a list format:
20+
#
21+
# ```
22+
# - [ ] incomplete
23+
# - [x] complete
24+
# ```
25+
#
26+
# Results
27+
# -------
28+
#
29+
# The following keys are written to the result hash:
30+
# :task_list_items - An array of TaskList::Item objects.
31+
class Filter < HTML::Pipeline::Filter
32+
33+
ListSelector = [
34+
# select UL/OL
35+
".//li[starts-with(text(),'[ ]')]/..",
36+
".//li[starts-with(text(),'[x]')]/..",
37+
# and those wrapped in Ps
38+
".//li/p[1][starts-with(text(),'[ ]')]/../..",
39+
".//li/p[1][starts-with(text(),'[x]')]/../.."
40+
].join(' | ').freeze
41+
42+
# Selects all LIs from a TaskList UL/OL
43+
ItemSelector = ".//li".freeze
44+
45+
# Selects first P tag of an LI, if present
46+
ItemParaSelector = ".//p[1]".freeze
47+
48+
attr_accessor :index
49+
50+
def initialize(*)
51+
@index = 0
52+
super
53+
end
54+
55+
# Private: increments and returns the next item index Integer.
56+
def next_index
57+
@index += 1
58+
end
59+
60+
# List of `TaskList::Item` objects that were recognized in the document.
61+
# This is available in the result hash as `:task_list_items`.
62+
#
63+
# Returns an Array of TaskList::Item objects.
64+
def task_list_items
65+
result[:task_list_items] ||= []
66+
end
67+
68+
# Renders the item checkbox in a span including the item index and state.
69+
#
70+
# Returns an HTML-safe String.
71+
def render_item_checkbox(item)
72+
%(<input type="checkbox"
73+
class="task-list-item-checkbox"
74+
data-item-index="#{item.index}"
75+
#{'checked="checked"' if item.complete?}
76+
data-item-complete="#{item.complete? ? 1 : 0}"
77+
disabled="disabled"
78+
/>)
79+
end
80+
81+
# Public: Marks up the task list item checkbox with metadata and behavior.
82+
#
83+
# NOTE: produces a string that, when assigned to a Node's `inner_html`,
84+
# will corrupt the string contents' encodings. Instead, we parse the
85+
# rendered HTML and explicitly set its encoding so that assignment will
86+
# not change the encodings.
87+
#
88+
# See [this pull](https://github.com/github/github/pull/8505) for details.
89+
#
90+
# Returns the marked up task list item Nokogiri::XML::NodeSet object.
91+
def render_task_list_item(item)
92+
Nokogiri::HTML.fragment <<-html, 'utf-8'
93+
<label>#{
94+
item.source.sub(TaskList::ItemPattern, render_item_checkbox(item))
95+
}</label>
96+
html
97+
end
98+
99+
# Public: Select all task lists from the `doc`.
100+
#
101+
# Returns an Array of Nokogiri::XML::Element objects for ordered and
102+
# unordered lists.
103+
def task_lists
104+
doc.xpath(ListSelector)
105+
end
106+
107+
# Public: filters a Nokogiri::XML::Element ordered/unordered list, marking
108+
# up the list items in order to add behavior and include metadata.
109+
#
110+
# Modifies the provided node.
111+
#
112+
# Returns nothing.
113+
def filter_list(node)
114+
add_css_class(node, 'task-list')
115+
116+
node.xpath(ItemSelector).each do |li|
117+
outer, inner =
118+
if p = li.xpath(ItemParaSelector)[0]
119+
[p, p.inner_html]
120+
else
121+
[li, li.inner_html]
122+
end
123+
if match = TaskList.item?(inner)
124+
item = TaskList::Item.new(next_index, match, inner)
125+
task_list_items << item
126+
127+
add_css_class(li, 'task-list-item')
128+
outer.inner_html = render_task_list_item(item)
129+
end
130+
end
131+
end
132+
133+
# Filters the source for task list items.
134+
#
135+
# Each item is wrapped in HTML to identify, style, and layer
136+
# useful behavior on top of.
137+
#
138+
# Modifications apply to the parsed document directly.
139+
#
140+
# Returns nothing.
141+
def filter!
142+
task_lists.each do |node|
143+
filter_list node
144+
end
145+
end
146+
147+
def call
148+
filter!
149+
doc
150+
end
151+
152+
# Private: adds a CSS class name to a node, respecting existing class
153+
# names.
154+
def add_css_class(node, *new_class_names)
155+
class_names = (node['class'] || '').split(' ')
156+
class_names.concat(new_class_names)
157+
node['class'] = class_names.uniq.join(' ')
158+
end
159+
end
160+
end

lib/task_list/updatable.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Include in models that need to update task list items.
2+
module TaskList::Updatable
3+
# Public: updates the task list item to match `checked`.
4+
#
5+
# - item: the item index Integer number
6+
# - checked: the Boolean checked value
7+
#
8+
# Returns true if updated.
9+
def update_task_list(item, checked, key = instrument_task_list_update_key)
10+
updated = TaskList.new(body).update(item, checked)
11+
attribute = respond_to?(:description=) ? :description : :body
12+
update_attributes! attribute => updated.source
13+
instrument :update_task_list, :checked => checked,
14+
:class_key => key if instrument_task_list_update?
15+
end
16+
17+
# Internal: whether to instrument task list updates.
18+
def instrument_task_list_update?
19+
true
20+
end
21+
22+
def instrument_task_list_update_key
23+
self.class.name.underscore
24+
end
25+
end

0 commit comments

Comments
 (0)