task_list 1.0.2
Sign up to get free protection for your applications and to get access to all the features.
- 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
|