hawkins 0.1.0
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/.document +5 -0
- data/.gitignore +30 -0
- data/.rubocop.yml +54 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +20 -0
- data/README.md +19 -0
- data/Rakefile +46 -0
- data/bin/hawkins +5 -0
- data/hawkins.gemspec +40 -0
- data/lib/hawkins.rb +45 -0
- data/lib/hawkins/cli.rb +190 -0
- data/lib/hawkins/guard.rb +268 -0
- data/lib/hawkins/isolation.rb +116 -0
- data/lib/hawkins/version.rb +3 -0
- data/test/resources/test_site/_config.yml +21 -0
- data/test/resources/test_site/_includes/footer.html +5 -0
- data/test/resources/test_site/_includes/header.html +8 -0
- data/test/resources/test_site/_includes/top.html +6 -0
- data/test/resources/test_site/_layouts/default.html +10 -0
- data/test/resources/test_site/_posts/2015-10-08-test-post.md +4 -0
- data/test/resources/test_site/_posts/2015-10-09-another-post.md +4 -0
- data/test/resources/test_site/assets/hawkins.css +7 -0
- data/test/resources/test_site/index.html +21 -0
- data/test/spec_helper.rb +59 -0
- data/test/test_hawkins.rb +134 -0
- metadata +319 -0
@@ -0,0 +1,268 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require 'benchmark'
|
4
|
+
require 'guard/plugin'
|
5
|
+
require 'hawkins'
|
6
|
+
require 'jekyll'
|
7
|
+
require 'thin'
|
8
|
+
|
9
|
+
# Most of this is courtesy of the guard-jekyll-plus gem at
|
10
|
+
# https://github.com/imathis/guard-jekyll-plus
|
11
|
+
|
12
|
+
module Guard
|
13
|
+
class Hawkins < Plugin
|
14
|
+
def initialize(options={})
|
15
|
+
super
|
16
|
+
|
17
|
+
default_extensions = ::Hawkins::DEFAULT_EXTENSIONS
|
18
|
+
|
19
|
+
@options = {
|
20
|
+
:extensions => [],
|
21
|
+
:config => Jekyll::Configuration.new.config_files({}),
|
22
|
+
:drafts => false,
|
23
|
+
:future => false,
|
24
|
+
:config_hash => nil,
|
25
|
+
:silent => false,
|
26
|
+
:msg_prefix => 'Jekyll'
|
27
|
+
}.merge(options)
|
28
|
+
|
29
|
+
@config = load_config(@options)
|
30
|
+
@source = local_path(@config['source'])
|
31
|
+
@destination = local_path(@config['destination'])
|
32
|
+
@msg_prefix = @options[:msg_prefix]
|
33
|
+
@app_prefix = 'Hawkins'
|
34
|
+
|
35
|
+
# Convert array of extensions into a regex for matching file extensions
|
36
|
+
# E.g. /\.md$|\.markdown$|\.html$/i
|
37
|
+
extensions = @options[:extensions].concat(default_extensions).flatten.uniq
|
38
|
+
@extensions = Regexp.new(
|
39
|
+
extensions.map { |e| (e << '$').gsub('\.', '\\.') }.join('|'),
|
40
|
+
true
|
41
|
+
)
|
42
|
+
|
43
|
+
# set Jekyll server thread to nil
|
44
|
+
@server_thread = nil
|
45
|
+
|
46
|
+
# Create a Jekyll site
|
47
|
+
@site = Jekyll::Site.new(@config)
|
48
|
+
end
|
49
|
+
|
50
|
+
def load_config(options)
|
51
|
+
config = jekyll_config(options)
|
52
|
+
|
53
|
+
# Override configuration with option values
|
54
|
+
config['show_drafts'] ||= options[:drafts]
|
55
|
+
config['future'] ||= options[:future]
|
56
|
+
config
|
57
|
+
end
|
58
|
+
|
59
|
+
def reload_config!
|
60
|
+
UI.info "Reloading Jekyll configuration!"
|
61
|
+
@config = load_config(@options)
|
62
|
+
end
|
63
|
+
|
64
|
+
def start
|
65
|
+
build
|
66
|
+
start_server
|
67
|
+
return if @config[:silent]
|
68
|
+
msg = "#{@app_prefix} "
|
69
|
+
msg += "watching and serving at #{@config['host']}:#{@config['port']}#{@config['baseurl']}"
|
70
|
+
UI.info(msg)
|
71
|
+
end
|
72
|
+
|
73
|
+
def reload
|
74
|
+
stop if !@server_thread.nil? && @server_thread.alive?
|
75
|
+
reload_config!
|
76
|
+
start
|
77
|
+
end
|
78
|
+
|
79
|
+
def reload_server
|
80
|
+
stop_server
|
81
|
+
start_server
|
82
|
+
end
|
83
|
+
|
84
|
+
def stop
|
85
|
+
stop_server
|
86
|
+
end
|
87
|
+
|
88
|
+
def run_on_modifications(paths)
|
89
|
+
# At this point we know @options[:config] is going to be an Array
|
90
|
+
# thanks to the call to jekyll_config earlier.
|
91
|
+
reload_config! if @options[:config].map { |f| paths.include?(f) }.any?
|
92
|
+
matched = jekyll_matches paths
|
93
|
+
unmatched = non_jekyll_matches paths
|
94
|
+
|
95
|
+
if matched.size > 0
|
96
|
+
build(matched, "Files changed: ", " ~ ".yellow)
|
97
|
+
elsif unmatched.size > 0
|
98
|
+
copy(unmatched)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def run_on_additions(paths)
|
103
|
+
matched = jekyll_matches paths
|
104
|
+
unmatched = non_jekyll_matches paths
|
105
|
+
|
106
|
+
if matched.size > 0
|
107
|
+
build(matched, "Files added: ", " + ".green)
|
108
|
+
elsif unmatched.size > 0
|
109
|
+
copy(unmatched)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def run_on_removals(paths)
|
114
|
+
matched = jekyll_matches paths
|
115
|
+
unmatched = non_jekyll_matches paths
|
116
|
+
|
117
|
+
if matched.size > 0
|
118
|
+
build(matched, "Files removed: ", " x ".red)
|
119
|
+
elsif unmatched.size > 0
|
120
|
+
remove(unmatched)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
def build(files=nil, message='', mark=nil)
|
127
|
+
UI.info "#{@msg_prefix} #{message}" + "building...".yellow unless @config[:silent]
|
128
|
+
if files
|
129
|
+
puts '| '
|
130
|
+
files.each { |file| puts '|' + mark + file }
|
131
|
+
puts '| '
|
132
|
+
end
|
133
|
+
elapsed = Benchmark.realtime { Jekyll::Site.new(@config).process }
|
134
|
+
unless @config[:silent]
|
135
|
+
msg = "#{@msg_prefix} " + "build completed in #{elapsed.round(2)}s ".green
|
136
|
+
msg += "#{@source} → #{@destination}"
|
137
|
+
UI.info(msg)
|
138
|
+
end
|
139
|
+
rescue
|
140
|
+
UI.error("#{@msg_prefix} build has failed") unless @config[:silent]
|
141
|
+
stop_server
|
142
|
+
throw :task_has_failed
|
143
|
+
end
|
144
|
+
|
145
|
+
# Copy static files to destination directory
|
146
|
+
#
|
147
|
+
def copy(files=[])
|
148
|
+
files = ignore_stitch_sources files
|
149
|
+
return false unless files.size > 0
|
150
|
+
begin
|
151
|
+
message = 'copied file'
|
152
|
+
message += 's' if files.size > 1
|
153
|
+
UI.info "#{@msg_prefix} #{message.green}" unless @config[:silent]
|
154
|
+
puts '| '
|
155
|
+
files.each do |file|
|
156
|
+
path = destination_path file
|
157
|
+
FileUtils.mkdir_p(File.dirname(path))
|
158
|
+
FileUtils.cp(file, path)
|
159
|
+
puts '|' + " → ".green + path
|
160
|
+
end
|
161
|
+
puts '| '
|
162
|
+
|
163
|
+
rescue
|
164
|
+
UI.error "#{@msg_prefix} copy has failed" unless @config[:silent]
|
165
|
+
UI.error e
|
166
|
+
stop_server
|
167
|
+
throw :task_has_failed
|
168
|
+
end
|
169
|
+
true
|
170
|
+
end
|
171
|
+
|
172
|
+
# Remove deleted source file/directories from destination
|
173
|
+
def remove(files=[])
|
174
|
+
# Ensure at least one file still exists (other scripts may clean up too)
|
175
|
+
return false unless files.select { |f| File.exist? f }.size > 0
|
176
|
+
begin
|
177
|
+
message = 'removed file'
|
178
|
+
message += 's' if files.size > 1
|
179
|
+
UI.info "#{@msg_prefix} #{message.red}" unless @config[:silent]
|
180
|
+
puts '| '
|
181
|
+
|
182
|
+
files.each do |file|
|
183
|
+
path = destination_path file
|
184
|
+
if File.exist?(path)
|
185
|
+
FileUtils.rm(path)
|
186
|
+
puts '|' + " x ".red + path
|
187
|
+
end
|
188
|
+
|
189
|
+
dir = File.dirname(path)
|
190
|
+
next unless Dir[File.join(dir, '*')].empty?
|
191
|
+
FileUtils.rm_r(dir)
|
192
|
+
puts '|' + " x ".red + dir
|
193
|
+
end
|
194
|
+
puts '| '
|
195
|
+
|
196
|
+
rescue
|
197
|
+
UI.error "#{@msg_prefix} remove has failed" unless @config[:silent]
|
198
|
+
UI.error e
|
199
|
+
stop_server
|
200
|
+
throw :task_has_failed
|
201
|
+
end
|
202
|
+
true
|
203
|
+
end
|
204
|
+
|
205
|
+
def jekyll_matches(paths)
|
206
|
+
paths.select { |file| file =~ @extensions }
|
207
|
+
end
|
208
|
+
|
209
|
+
def non_jekyll_matches(paths)
|
210
|
+
paths.select { |file| !file.match(/^_/) && !file.match(@extensions) }
|
211
|
+
end
|
212
|
+
|
213
|
+
def jekyll_config(options)
|
214
|
+
if options[:config_hash]
|
215
|
+
config = options[:config_hash]
|
216
|
+
elsif options[:config]
|
217
|
+
options[:config] = [options[:config]] unless options[:config].is_a? Array
|
218
|
+
config = options
|
219
|
+
end
|
220
|
+
Jekyll.configuration(config)
|
221
|
+
end
|
222
|
+
|
223
|
+
# TODO Use Pathname.relative_path_from or similar here
|
224
|
+
def local_path(path)
|
225
|
+
Dir.chdir('.')
|
226
|
+
current = Dir.pwd
|
227
|
+
path = path.sub current, ''
|
228
|
+
if path == ''
|
229
|
+
'./'
|
230
|
+
else
|
231
|
+
path.sub(/^\//, '')
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def destination_path(file)
|
236
|
+
if @source =~ /^\./
|
237
|
+
File.join(@destination, file)
|
238
|
+
else
|
239
|
+
file.sub(/^#{@source}/, "#{@destination}")
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
def start_server
|
244
|
+
if @server_thread.nil?
|
245
|
+
@server_thread = Thread.new do
|
246
|
+
Thin::Server.start(@config['host'], @config['port'], :signals => false) do
|
247
|
+
require 'rack/livereload'
|
248
|
+
require 'hawkins/isolation'
|
249
|
+
use Rack::LiveReload,
|
250
|
+
:min_delay => 500,
|
251
|
+
:max_delay => 2000,
|
252
|
+
:no_swf => true,
|
253
|
+
:source => :vendored
|
254
|
+
run ::Hawkins::IsolationInjector.new
|
255
|
+
end
|
256
|
+
end
|
257
|
+
UI.info "#{@app_prefix} running Rack" unless @config[:silent]
|
258
|
+
else
|
259
|
+
UI.warning "#{@app_prefix} using an old server thread!"
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
def stop_server
|
264
|
+
@server_thread.kill unless @server_thread.nil?
|
265
|
+
@server_thread = nil
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
@@ -0,0 +1,116 @@
|
|
1
|
+
require 'pathname'
|
2
|
+
require 'rack'
|
3
|
+
require 'safe_yaml/load'
|
4
|
+
require 'set'
|
5
|
+
|
6
|
+
module Hawkins
|
7
|
+
class IsolationInjector
|
8
|
+
attr_reader :site_root
|
9
|
+
attr_reader :isolation_file
|
10
|
+
|
11
|
+
def initialize(options={})
|
12
|
+
@options = options
|
13
|
+
@site_root = @options[:site_root] || Jekyll.configuration({})['destination']
|
14
|
+
@isolation_file = @options[:isolation_file] || Hawkins::ISOLATION_FILE
|
15
|
+
SafeYAML::OPTIONS[:default_mode] = :safe
|
16
|
+
end
|
17
|
+
|
18
|
+
def call(env)
|
19
|
+
req = Rack::Request.new(env)
|
20
|
+
path = Pathname.new(req.path_info).relative_path_from(Pathname.new('/'))
|
21
|
+
path = File.join(site_root, path.to_s)
|
22
|
+
|
23
|
+
path = File.join(path, "index.html") if File.directory?(path)
|
24
|
+
path = Pathname.new(path).cleanpath.to_s
|
25
|
+
|
26
|
+
files = Dir[File.join(site_root, "**/*")]
|
27
|
+
if files.include?(path)
|
28
|
+
mime = mime(path)
|
29
|
+
file = file_info(path)
|
30
|
+
body = file[:body]
|
31
|
+
time = file[:time]
|
32
|
+
hdrs = {'Last-Modified' => time}
|
33
|
+
|
34
|
+
if time == req.env['HTTP_IF_MODIFIED_SINCE']
|
35
|
+
[304, hdrs, []]
|
36
|
+
else
|
37
|
+
hdrs.update(
|
38
|
+
'Content-length' => body.bytesize.to_s,
|
39
|
+
'Content-Type' => mime
|
40
|
+
)
|
41
|
+
[200, hdrs, [body]]
|
42
|
+
end
|
43
|
+
else
|
44
|
+
handle_404(req, path)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def handle_404(req, true_path)
|
49
|
+
if File.exist?(isolation_file)
|
50
|
+
file = true_path
|
51
|
+
# Use a wildcard since the origin file could be anything
|
52
|
+
file = "#{File.basename(file, File.extname(file))}.*".force_encoding('utf-8')
|
53
|
+
|
54
|
+
config = SafeYAML.load_file(isolation_file)
|
55
|
+
|
56
|
+
file_set = Set.new(config['include'])
|
57
|
+
|
58
|
+
# Prevent loops. If it's already in 'include'
|
59
|
+
# then we've gone through here before.
|
60
|
+
return static_error if file_set.include?(file)
|
61
|
+
|
62
|
+
config['include'] = file_set.add(file).to_a
|
63
|
+
|
64
|
+
File.open(isolation_file, 'w') do |f|
|
65
|
+
YAML.dump(config, f)
|
66
|
+
end
|
67
|
+
|
68
|
+
response = <<-PAGE.gsub(/^\s*/, '')
|
69
|
+
<!DOCTYPE HTML>
|
70
|
+
<html lang="en-US">
|
71
|
+
<head>
|
72
|
+
<meta charset="UTF-8">
|
73
|
+
<title>Rendering #{req.path_info}</title>
|
74
|
+
</head>
|
75
|
+
<body>
|
76
|
+
<h1>Hold on while I render that page for you!</h1>
|
77
|
+
</body>
|
78
|
+
PAGE
|
79
|
+
|
80
|
+
headers = {}
|
81
|
+
headers['Content-Length'] = response.bytesize.to_s
|
82
|
+
headers['Content-Type'] = 'text/html'
|
83
|
+
headers['Connection'] = 'keep-alive'
|
84
|
+
[200, headers, [response]]
|
85
|
+
else
|
86
|
+
static_error
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def static_error
|
91
|
+
error_page = File.join(site_root, "404.html")
|
92
|
+
if File.exist?(error_page)
|
93
|
+
body = file_info(error_page)[:body]
|
94
|
+
mime = mime(error_page)
|
95
|
+
else
|
96
|
+
body = "Not found"
|
97
|
+
mime = "text/plain"
|
98
|
+
end
|
99
|
+
[404, {"Content-Type" => mime, "Content-length" => body.bytesize.to_s}, [body]]
|
100
|
+
end
|
101
|
+
|
102
|
+
def mime(path_info)
|
103
|
+
Rack::Mime.mime_type(File.extname(path_info))
|
104
|
+
end
|
105
|
+
|
106
|
+
def file_info(path)
|
107
|
+
File.open(path, 'r') do |f|
|
108
|
+
{
|
109
|
+
:body => f.read,
|
110
|
+
:time => f.mtime.httpdate,
|
111
|
+
:expand_path => path
|
112
|
+
}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# Do not use tabs in this file!
|
2
|
+
# Required for running on Ruby 1.9
|
3
|
+
encoding: utf-8
|
4
|
+
|
5
|
+
# Change this if we ever need the site to reside in a sub-directory
|
6
|
+
baseurl: ''
|
7
|
+
|
8
|
+
permalink: /:year/:title.html
|
9
|
+
paginate: 8
|
10
|
+
paginate_path: /news/page:num
|
11
|
+
|
12
|
+
markdown: kramdown
|
13
|
+
kramdown:
|
14
|
+
# Use GitHub flavor markdown
|
15
|
+
input: GFM
|
16
|
+
hard_wrap: false
|
17
|
+
|
18
|
+
# See http://jekyllrb.com/docs/configuration/#frontmatter-defaults
|
19
|
+
defaults:
|
20
|
+
- scope: { type: "posts" }
|
21
|
+
values: { layout: "default" }
|