task_list 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +21 -0
- data/.travis.yml +9 -0
- data/Gemfile +2 -0
- data/LICENSE +21 -0
- data/README.md +76 -0
- data/Rakefile +9 -0
- data/app/assets/javascripts/task_list.coffee +237 -0
- data/app/assets/stylesheets/task_list.scss +20 -0
- data/bower.json +26 -0
- data/config.ru +30 -0
- data/lib/task_list.rb +32 -0
- data/lib/task_list/filter.rb +149 -0
- data/lib/task_list/railtie.rb +20 -0
- data/lib/task_list/summary.rb +30 -0
- data/lib/task_list/version.rb +3 -0
- data/script/bootstrap +13 -0
- data/script/cibuild +6 -0
- data/script/testsuite +19 -0
- data/task-lists.gemspec +28 -0
- data/test/functional/helpers/remote.coffee +3 -0
- data/test/functional/test_task_lists_behavior.html +103 -0
- data/test/index.html +12 -0
- data/test/run-qunit.coffee +50 -0
- data/test/task_list/filter_test.rb +142 -0
- data/test/task_list/summary_test.rb +40 -0
- data/test/task_list_test.rb +33 -0
- data/test/test_helper.rb +3 -0
- data/test/unit/test_events.coffee +96 -0
- data/test/unit/test_updates.coffee +566 -0
- data/test/units.coffee +2 -0
- data/test/units.css +1 -0
- metadata +198 -0
data/lib/task_list.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'task_list/summary'
|
2
|
+
require 'task_list/version'
|
3
|
+
|
4
|
+
# encoding: utf-8
|
5
|
+
class TaskList
|
6
|
+
attr_reader :record
|
7
|
+
|
8
|
+
# `record` is the resource with the Markdown source text with task list items
|
9
|
+
# following this syntax:
|
10
|
+
#
|
11
|
+
# - [ ] a task list item
|
12
|
+
# - [ ] another item
|
13
|
+
# - [x] a completed item
|
14
|
+
#
|
15
|
+
def initialize(record)
|
16
|
+
@record = record
|
17
|
+
end
|
18
|
+
|
19
|
+
# Public: return the TaskList::Summary for this task list.
|
20
|
+
#
|
21
|
+
# Returns a TaskList::Summary.
|
22
|
+
def summary
|
23
|
+
@summary ||= TaskList::Summary.new(record.task_list_items)
|
24
|
+
end
|
25
|
+
|
26
|
+
class Item < Struct.new(:checkbox_text, :source)
|
27
|
+
Complete = /\[[xX]\]/.freeze # see TaskList::Filter
|
28
|
+
def complete?
|
29
|
+
checkbox_text =~ Complete
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,149 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'html/pipeline'
|
3
|
+
require 'task_list'
|
4
|
+
|
5
|
+
class TaskList
|
6
|
+
# Returns a `Nokogiri::DocumentFragment` object.
|
7
|
+
def self.filter(*args)
|
8
|
+
Filter.call(*args)
|
9
|
+
end
|
10
|
+
|
11
|
+
# TaskList filter replaces task list item markers (`[ ]` and `[x]`) with
|
12
|
+
# checkboxes, marked up with metadata and behavior.
|
13
|
+
#
|
14
|
+
# This should be run on the HTML generated by the Markdown filter, after the
|
15
|
+
# SanitizationFilter.
|
16
|
+
#
|
17
|
+
# Syntax
|
18
|
+
# ------
|
19
|
+
#
|
20
|
+
# Task list items must be in a list format:
|
21
|
+
#
|
22
|
+
# ```
|
23
|
+
# - [ ] incomplete
|
24
|
+
# - [x] complete
|
25
|
+
# ```
|
26
|
+
#
|
27
|
+
# Results
|
28
|
+
# -------
|
29
|
+
#
|
30
|
+
# The following keys are written to the result hash:
|
31
|
+
# :task_list_items - An array of TaskList::Item objects.
|
32
|
+
class Filter < HTML::Pipeline::Filter
|
33
|
+
|
34
|
+
Incomplete = "[ ]".freeze
|
35
|
+
Complete = "[x]".freeze
|
36
|
+
|
37
|
+
IncompletePattern = /\[[[:space:]]\]/.freeze # matches all whitespace
|
38
|
+
CompletePattern = /\[[xX]\]/.freeze # matches any capitalization
|
39
|
+
|
40
|
+
# Pattern used to identify all task list items.
|
41
|
+
# Useful when you need iterate over all items.
|
42
|
+
ItemPattern = /
|
43
|
+
^
|
44
|
+
(?:\s*[-+*]|(?:\d+\.))? # optional list prefix
|
45
|
+
\s* # optional whitespace prefix
|
46
|
+
( # checkbox
|
47
|
+
#{CompletePattern}|
|
48
|
+
#{IncompletePattern}
|
49
|
+
)
|
50
|
+
(?=\s) # followed by whitespace
|
51
|
+
/x
|
52
|
+
|
53
|
+
ListItemSelector = ".//li[task_list_item(.)]".freeze
|
54
|
+
|
55
|
+
class XPathSelectorFunction
|
56
|
+
def self.task_list_item(nodes)
|
57
|
+
nodes if nodes.text =~ ItemPattern
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Selects first P tag of an LI, if present
|
62
|
+
ItemParaSelector = "./p[1]".freeze
|
63
|
+
|
64
|
+
# List of `TaskList::Item` objects that were recognized in the document.
|
65
|
+
# This is available in the result hash as `:task_list_items`.
|
66
|
+
#
|
67
|
+
# Returns an Array of TaskList::Item objects.
|
68
|
+
def task_list_items
|
69
|
+
result[:task_list_items] ||= []
|
70
|
+
end
|
71
|
+
|
72
|
+
# Renders the item checkbox in a span including the item state.
|
73
|
+
#
|
74
|
+
# Returns an HTML-safe String.
|
75
|
+
def render_item_checkbox(item)
|
76
|
+
%(<input type="checkbox"
|
77
|
+
class="task-list-item-checkbox"
|
78
|
+
#{'checked="checked"' if item.complete?}
|
79
|
+
disabled="disabled"
|
80
|
+
/>)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Public: Marks up the task list item checkbox with metadata and behavior.
|
84
|
+
#
|
85
|
+
# NOTE: produces a string that, when assigned to a Node's `inner_html`,
|
86
|
+
# will corrupt the string contents' encodings. Instead, we parse the
|
87
|
+
# rendered HTML and explicitly set its encoding so that assignment will
|
88
|
+
# not change the encodings.
|
89
|
+
#
|
90
|
+
# See [this pull](https://github.com/github/github/pull/8505) for details.
|
91
|
+
#
|
92
|
+
# Returns the marked up task list item Nokogiri::XML::NodeSet object.
|
93
|
+
def render_task_list_item(item)
|
94
|
+
Nokogiri::HTML.fragment \
|
95
|
+
item.source.sub(ItemPattern, render_item_checkbox(item)), 'utf-8'
|
96
|
+
end
|
97
|
+
|
98
|
+
# Public: Select all task lists from the `doc`.
|
99
|
+
#
|
100
|
+
# Returns an Array of Nokogiri::XML::Element objects for ordered and
|
101
|
+
# unordered lists.
|
102
|
+
def list_items
|
103
|
+
doc.xpath(ListItemSelector, XPathSelectorFunction)
|
104
|
+
end
|
105
|
+
|
106
|
+
# Filters the source for task list items.
|
107
|
+
#
|
108
|
+
# Each item is wrapped in HTML to identify, style, and layer
|
109
|
+
# useful behavior on top of.
|
110
|
+
#
|
111
|
+
# Modifications apply to the parsed document directly.
|
112
|
+
#
|
113
|
+
# Returns nothing.
|
114
|
+
def filter!
|
115
|
+
list_items.reverse.each do |li|
|
116
|
+
add_css_class(li.parent, 'task-list')
|
117
|
+
|
118
|
+
outer, inner =
|
119
|
+
if p = li.xpath(ItemParaSelector)[0]
|
120
|
+
[p, p.inner_html]
|
121
|
+
else
|
122
|
+
[li, li.inner_html]
|
123
|
+
end
|
124
|
+
if match = (inner.chomp =~ ItemPattern && $1)
|
125
|
+
item = TaskList::Item.new(match, inner)
|
126
|
+
# prepend because we're iterating in reverse
|
127
|
+
task_list_items.unshift item
|
128
|
+
|
129
|
+
add_css_class(li, 'task-list-item')
|
130
|
+
outer.inner_html = render_task_list_item(item)
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def call
|
136
|
+
filter!
|
137
|
+
doc
|
138
|
+
end
|
139
|
+
|
140
|
+
# Private: adds a CSS class name to a node, respecting existing class
|
141
|
+
# names.
|
142
|
+
def add_css_class(node, *new_class_names)
|
143
|
+
class_names = (node['class'] || '').split(' ')
|
144
|
+
return if new_class_names.all? { |klass| class_names.include?(klass) }
|
145
|
+
class_names.concat(new_class_names)
|
146
|
+
node['class'] = class_names.uniq.join(' ')
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class TaskList
|
2
|
+
|
3
|
+
def self.root_path
|
4
|
+
@root_path ||= Pathname.new(File.expand_path("../../../", __FILE__))
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.asset_paths
|
8
|
+
@paths ||= Dir[root_path.join("app/assets/*")]
|
9
|
+
end
|
10
|
+
|
11
|
+
if defined? ::Rails::Railtie
|
12
|
+
class Railtie < ::Rails::Railtie
|
13
|
+
initializer "task_list" do |app|
|
14
|
+
TaskList.asset_paths.each do |path|
|
15
|
+
app.config.assets.paths << path
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# encoding: utf-8
|
2
|
+
require 'html/pipeline'
|
3
|
+
require 'task_list'
|
4
|
+
|
5
|
+
class TaskList
|
6
|
+
# Provides a summary of provided TaskList `items`.
|
7
|
+
#
|
8
|
+
# `items` is an Array of TaskList::Item objects.
|
9
|
+
class Summary < Struct.new(:items)
|
10
|
+
# Public: returns true if there are any TaskList::Item objects.
|
11
|
+
def items?
|
12
|
+
item_count > 0
|
13
|
+
end
|
14
|
+
|
15
|
+
# Public: returns the number of TaskList::Item objects.
|
16
|
+
def item_count
|
17
|
+
items.size
|
18
|
+
end
|
19
|
+
|
20
|
+
# Public: returns the number of complete TaskList::Item objects.
|
21
|
+
def complete_count
|
22
|
+
items.select{ |i| i.complete? }.size
|
23
|
+
end
|
24
|
+
|
25
|
+
# Public: returns the number of incomplete TaskList::Item objects.
|
26
|
+
def incomplete_count
|
27
|
+
items.select{ |i| !i.complete? }.size
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
data/script/bootstrap
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
#!/bin/sh
|
2
|
+
set -e 0
|
3
|
+
|
4
|
+
if ! bundle check 1>/dev/null 2>&1; then
|
5
|
+
bundle install --no-color --binstubs --path vendor/gems
|
6
|
+
fi
|
7
|
+
|
8
|
+
if ! npm list bower 2>&1 | grep 0.8.5 >/dev/null; then
|
9
|
+
# npm install bower
|
10
|
+
npm install git://github.com/twitter/bower.git
|
11
|
+
fi
|
12
|
+
|
13
|
+
bower install --no-color
|
data/script/cibuild
ADDED
data/script/testsuite
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
root = File.expand_path("../..", __FILE__)
|
4
|
+
Dir.chdir root
|
5
|
+
|
6
|
+
port = ARGV[0] || 4000
|
7
|
+
|
8
|
+
pid = fork do
|
9
|
+
$stderr.reopen "/dev/null" # silence WEBrick output
|
10
|
+
exec 'bundle', 'exec', 'rackup', '-p', port.to_s
|
11
|
+
end
|
12
|
+
sleep 1
|
13
|
+
|
14
|
+
status = system('phantomjs', "#{root}/test/run-qunit.coffee", "http://localhost:#{port}/test/index.html")
|
15
|
+
|
16
|
+
Process.kill 'SIGINT', pid
|
17
|
+
Process.wait pid
|
18
|
+
|
19
|
+
exit status
|
data/task-lists.gemspec
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'task_list/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |gem|
|
7
|
+
gem.name = "task_list"
|
8
|
+
gem.version = TaskList::VERSION
|
9
|
+
gem.authors = ["Matt Todd"]
|
10
|
+
gem.email = ["matt@github.com"]
|
11
|
+
gem.description = %q{GitHub-flavored-Markdown TaskList components}
|
12
|
+
gem.summary = %q{GitHub-flavored-Markdown TaskList components}
|
13
|
+
|
14
|
+
gem.files = `git ls-files`.split($/)
|
15
|
+
gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
|
16
|
+
gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
|
17
|
+
gem.require_paths = ["lib"]
|
18
|
+
|
19
|
+
gem.add_dependency "html-pipeline"
|
20
|
+
|
21
|
+
gem.add_development_dependency "github-markdown"
|
22
|
+
gem.add_development_dependency "rake"
|
23
|
+
gem.add_development_dependency "coffee-script"
|
24
|
+
gem.add_development_dependency "json"
|
25
|
+
gem.add_development_dependency "rack"
|
26
|
+
gem.add_development_dependency "sprockets"
|
27
|
+
gem.add_development_dependency "minitest", "~> 5.3.2"
|
28
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8">
|
5
|
+
<script type="text/javascript" src="/assets/functional/helpers/remote.js"></script>
|
6
|
+
<script type="text/javascript">
|
7
|
+
function logEvent(event) {
|
8
|
+
console.log(event)
|
9
|
+
$('.log').prepend("<li>" + event.type + "</li>")
|
10
|
+
}
|
11
|
+
|
12
|
+
setInterval(function() {
|
13
|
+
if (!$('.log li:first').first().hasClass("timestamp"))
|
14
|
+
$('.log').prepend("<li class='timestamp'><time>" + new Date + "</time></li>")
|
15
|
+
}, 3000)
|
16
|
+
|
17
|
+
$(document).on('tasklist:enabled', logEvent)
|
18
|
+
$(document).on('tasklist:disabled', logEvent)
|
19
|
+
$(document).on('tasklist:change', '.js-task-list-field', function(event){
|
20
|
+
logEvent(event)
|
21
|
+
$(this).closest('.js-task-list-container').taskList('disable')
|
22
|
+
})
|
23
|
+
$(document).on('tasklist:changed', '.js-task-list-field', function(event){
|
24
|
+
logEvent(event)
|
25
|
+
$(this).closest('form').submit()
|
26
|
+
})
|
27
|
+
|
28
|
+
$(document).on('ajaxStart', logEvent)
|
29
|
+
$(document).on('ajaxSuccess', '.js-task-list-container', function(event){
|
30
|
+
logEvent(event)
|
31
|
+
$(this).taskList()
|
32
|
+
})
|
33
|
+
</script>
|
34
|
+
|
35
|
+
<style>
|
36
|
+
body {
|
37
|
+
font: 13px Helvetica;
|
38
|
+
padding: 20px;
|
39
|
+
}
|
40
|
+
input {
|
41
|
+
font-size: 20px;
|
42
|
+
}
|
43
|
+
time {
|
44
|
+
font-size: 10px;
|
45
|
+
font-style: oblique;
|
46
|
+
}
|
47
|
+
|
48
|
+
.task-list {
|
49
|
+
list-style-type: none;
|
50
|
+
}
|
51
|
+
.task-list .task-list-item {
|
52
|
+
opacity: 0.75;
|
53
|
+
}
|
54
|
+
.task-list .task-list-item.enabled {
|
55
|
+
opacity: 1.0;
|
56
|
+
}
|
57
|
+
|
58
|
+
.log {
|
59
|
+
position: absolute;
|
60
|
+
top: 20px;
|
61
|
+
right: 50px;
|
62
|
+
color: #222;
|
63
|
+
list-style: none;
|
64
|
+
margin: 0;
|
65
|
+
padding: 0;
|
66
|
+
}
|
67
|
+
</style>
|
68
|
+
</head>
|
69
|
+
<body>
|
70
|
+
<div class="js-task-list-container js-task-list-enable">
|
71
|
+
<div class="markdown">
|
72
|
+
<ul class="task-list">
|
73
|
+
<li class="task-list-item">
|
74
|
+
<input type="checkbox" class="task-list-item-checkbox" disabled />
|
75
|
+
I'm a task list item
|
76
|
+
</li>
|
77
|
+
<li class="task-list-item">
|
78
|
+
<input type="checkbox" class="task-list-item-checkbox" disabled />
|
79
|
+
with non-breaking space
|
80
|
+
</li>
|
81
|
+
<li class="task-list-item">
|
82
|
+
<input type="checkbox" class="task-list-item-checkbox" disabled checked />
|
83
|
+
completed, lower
|
84
|
+
</li>
|
85
|
+
<li class="task-list-item">
|
86
|
+
<input type="checkbox" class="task-list-item-checkbox" disabled checked />
|
87
|
+
completed capitalized
|
88
|
+
</li>
|
89
|
+
</ul>
|
90
|
+
</div>
|
91
|
+
<form action="/update" method="POST" data-remote data-type="json">
|
92
|
+
<textarea name="comment[body]" class="js-task-list-field" cols="40" rows="10">
|
93
|
+
- [ ] I'm a task list item
|
94
|
+
- [ ] with non-breaking space
|
95
|
+
- [x] completed, lower
|
96
|
+
- [X] completed capitalized</textarea>
|
97
|
+
</form>
|
98
|
+
</div>
|
99
|
+
|
100
|
+
<ul class="log">
|
101
|
+
</ul>
|
102
|
+
</body>
|
103
|
+
</html>
|
data/test/index.html
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html lang="en">
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8">
|
5
|
+
<link rel="stylesheet" href="/assets/units.css">
|
6
|
+
<script type="text/javascript" src="/assets/units.js"></script>
|
7
|
+
</head>
|
8
|
+
<body>
|
9
|
+
<div id="qunit"></div>
|
10
|
+
<div id="qunit-fixture"></div>
|
11
|
+
</body>
|
12
|
+
</html>
|
@@ -0,0 +1,50 @@
|
|
1
|
+
fs = require 'fs'
|
2
|
+
print = (s) -> fs.write "/dev/stderr", s, 'w'
|
3
|
+
|
4
|
+
page = new WebPage()
|
5
|
+
page.onConsoleMessage = (msg) -> console.error msg
|
6
|
+
|
7
|
+
timeoutId = null
|
8
|
+
deferTimeout = ->
|
9
|
+
clearTimeout timeoutId if timeoutId
|
10
|
+
timeoutId = setTimeout ->
|
11
|
+
console.error "Timeout"
|
12
|
+
phantom.exit 1
|
13
|
+
, 3000
|
14
|
+
|
15
|
+
page.open phantom.args[0], ->
|
16
|
+
deferTimeout()
|
17
|
+
|
18
|
+
setInterval ->
|
19
|
+
tests = page.evaluate ->
|
20
|
+
tests = document.getElementById('qunit-tests')?.children
|
21
|
+
return unless tests
|
22
|
+
for test in tests when test.className isnt 'running' and not test.recorded
|
23
|
+
test.recorded = true
|
24
|
+
if test.className is 'pass'
|
25
|
+
'.'
|
26
|
+
else if test.className is 'fail'
|
27
|
+
'F'
|
28
|
+
|
29
|
+
return unless tests
|
30
|
+
for test in tests when test
|
31
|
+
deferTimeout()
|
32
|
+
print test
|
33
|
+
|
34
|
+
result = page.evaluate ->
|
35
|
+
result = document.getElementById('qunit-testresult')
|
36
|
+
tests = document.getElementById('qunit-tests').children
|
37
|
+
|
38
|
+
if result.innerText.match /completed/
|
39
|
+
console.error ""
|
40
|
+
|
41
|
+
for test in tests when test.className is 'fail'
|
42
|
+
console.error test.innerText
|
43
|
+
|
44
|
+
console.error result.innerText
|
45
|
+
return parseInt result.getElementsByClassName('failed')[0].innerText
|
46
|
+
|
47
|
+
return
|
48
|
+
|
49
|
+
phantom.exit result if result?
|
50
|
+
, 100
|