glim 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/glim +1538 -0
- data/lib/cache.rb +66 -0
- data/lib/commands.rb +261 -0
- data/lib/exception.rb +18 -0
- data/lib/liquid_ext.rb +249 -0
- data/lib/local_server.rb +375 -0
- data/lib/log_and_profile.rb +115 -0
- data/lib/version.rb +3 -0
- metadata +178 -0
data/bin/glim
ADDED
@@ -0,0 +1,1538 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'version'
|
3
|
+
require 'exception'
|
4
|
+
require 'log_and_profile'
|
5
|
+
require 'cache'
|
6
|
+
require 'commands'
|
7
|
+
require 'liquid_ext'
|
8
|
+
require 'csv'
|
9
|
+
require 'digest'
|
10
|
+
require 'enumerator'
|
11
|
+
require 'fileutils'
|
12
|
+
require 'find'
|
13
|
+
require 'json'
|
14
|
+
require 'pathname'
|
15
|
+
require 'kramdown'
|
16
|
+
require 'liquid'
|
17
|
+
require 'mercenary'
|
18
|
+
require 'bundler/setup'
|
19
|
+
|
20
|
+
module Util
|
21
|
+
module_function
|
22
|
+
|
23
|
+
def slugify(input, preserve_case: false)
|
24
|
+
if input
|
25
|
+
res = input.gsub(/[^[:alnum:]]+/, '-').gsub(/\A-|-\z/, '')
|
26
|
+
preserve_case ? res : res.downcase
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def parse_date(maybe_a_date)
|
31
|
+
if maybe_a_date.is_a?(Time)
|
32
|
+
maybe_a_date
|
33
|
+
elsif maybe_a_date.is_a?(Date)
|
34
|
+
maybe_a_date.to_time
|
35
|
+
elsif maybe_a_date.is_a?(String)
|
36
|
+
begin
|
37
|
+
Liquid::Utils.to_date(maybe_a_date)
|
38
|
+
rescue => e
|
39
|
+
$log.warn("Failed to parse date ‘#{maybe_a_date}’: #{e}")
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def titlecase(input)
|
46
|
+
unless input.nil?
|
47
|
+
words = /\A\p{^Lower}+\z|\b\p{Upper}\p{^Upper}+?\b/
|
48
|
+
upcase = /^([\W\d]*)(\w[-\w]*)|\b((?!(?:else|from|over|then|when)\b)\w[-\w]{3,}|\w[-\w]*[\W\d]*$)/
|
49
|
+
input.gsub(/[ _-]+/, ' ').gsub(words) { $&.downcase }.gsub(upcase) { $1 ? ($1 + $2[0].upcase + $2[1..-1]) : ($&[0].upcase + $&[1..-1]) }
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def relative_path(path, dir)
|
54
|
+
Pathname.new(path).relative_path_from(Pathname.new(dir)).to_s
|
55
|
+
end
|
56
|
+
|
57
|
+
def deep_merge(old_hash, new_hash)
|
58
|
+
old_hash.merge(new_hash) do |key, old_value, new_value|
|
59
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
60
|
+
deep_merge(old_value, new_value)
|
61
|
+
else
|
62
|
+
new_hash.key?(key) ? new_value : old_value
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def find_files(dir, glob)
|
68
|
+
if File.directory?(dir)
|
69
|
+
Find.find(dir).each do |path|
|
70
|
+
if File.fnmatch?(glob, path, File::FNM_PATHNAME|File::FNM_CASEFOLD|File::FNM_EXTGLOB) && !File.directory?(path)
|
71
|
+
yield(path)
|
72
|
+
elsif File.basename(path).start_with?('.')
|
73
|
+
Find.prune
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
module Jekyll
|
81
|
+
class << self
|
82
|
+
attr_accessor :sites
|
83
|
+
end
|
84
|
+
|
85
|
+
class Plugin
|
86
|
+
PRIORITIES = {
|
87
|
+
:lowest => -500,
|
88
|
+
:lower => -250,
|
89
|
+
:low => -100,
|
90
|
+
:normal => 0,
|
91
|
+
:high => 100,
|
92
|
+
:higher => 250,
|
93
|
+
:highest => 500,
|
94
|
+
}
|
95
|
+
|
96
|
+
def self.priority(priority = nil)
|
97
|
+
if priority.is_a?(Symbol) && PRIORITIES.key?(priority)
|
98
|
+
@priority = PRIORITIES[priority]
|
99
|
+
elsif priority.is_a?(Numeric)
|
100
|
+
@priority = priority
|
101
|
+
end
|
102
|
+
@priority || PRIORITIES[:normal]
|
103
|
+
end
|
104
|
+
|
105
|
+
def self.<=>(other)
|
106
|
+
other.priority <=> self.priority
|
107
|
+
end
|
108
|
+
|
109
|
+
def self.safe(flag)
|
110
|
+
end
|
111
|
+
|
112
|
+
def initialize(config = {})
|
113
|
+
@config = config
|
114
|
+
end
|
115
|
+
|
116
|
+
# ====================
|
117
|
+
# = Track subclasses =
|
118
|
+
# ====================
|
119
|
+
|
120
|
+
@@plugins = []
|
121
|
+
|
122
|
+
def self.inherited(subclass)
|
123
|
+
@@plugins << subclass
|
124
|
+
end
|
125
|
+
|
126
|
+
def self.plugins_of_type(klass)
|
127
|
+
@@plugins.select { |candidate| candidate < klass }
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
Generator = Class.new(Plugin)
|
132
|
+
Converter = Class.new(Plugin)
|
133
|
+
Command = Class.new(Plugin)
|
134
|
+
|
135
|
+
class Hooks
|
136
|
+
def self.register(collection, event, &proc)
|
137
|
+
@hooks ||= []
|
138
|
+
@hooks << { :collection => collection, :event => event, :proc => proc }
|
139
|
+
$log.debug("Register hook for event ‘#{event}’ in collection ‘#{collection}’")
|
140
|
+
end
|
141
|
+
|
142
|
+
def self.invoke(collection, event, file: nil, payload: nil)
|
143
|
+
@hooks.select { |hook| hook[:event] == event && hook[:collection] == collection }.each do |hook|
|
144
|
+
$log.debug("TODO Invoke #{hook[:proc]}")
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
module Glim
|
151
|
+
class Filter < Jekyll::Plugin
|
152
|
+
class << self
|
153
|
+
def transforms(hash = nil)
|
154
|
+
@transforms = hash || @transforms
|
155
|
+
end
|
156
|
+
|
157
|
+
def extensions(hash = nil)
|
158
|
+
@extensions = hash || @extensions
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def initialize(site)
|
163
|
+
end
|
164
|
+
|
165
|
+
def transform(content, page, options)
|
166
|
+
content
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
module Filters
|
171
|
+
class Liquid < Glim::Filter
|
172
|
+
transforms 'liquid' => '*'
|
173
|
+
priority :higher
|
174
|
+
|
175
|
+
def initialize(site)
|
176
|
+
@site, @options = site, {
|
177
|
+
:strict_variables => site.config['liquid']['strict_variables'],
|
178
|
+
:strict_filters => site.config['liquid']['strict_filters'],
|
179
|
+
}
|
180
|
+
end
|
181
|
+
|
182
|
+
def transform(content, page, options)
|
183
|
+
begin
|
184
|
+
template = ::Liquid::Template.parse(Glim.preprocess_template(content))
|
185
|
+
template.render!({ 'site' => @site.to_liquid, 'page' => page.to_liquid }, @options)
|
186
|
+
rescue ::Liquid::Error => e
|
187
|
+
raise Glim::Error.new("While expanding liquid tags in: #{page}", e)
|
188
|
+
end
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
class Markdown < Glim::Filter
|
193
|
+
transforms 'markdown' => 'html'
|
194
|
+
priority :lower
|
195
|
+
|
196
|
+
def initialize(site)
|
197
|
+
self.class.extensions('markdown' => site.config['markdown_ext'].split(',').each { |ext| ext.strip }.reject { |e| e.empty? })
|
198
|
+
|
199
|
+
legacy = {
|
200
|
+
'syntax_highlighter' => site.config['highlighter'],
|
201
|
+
'syntax_highlighter_opts' => {},
|
202
|
+
}
|
203
|
+
|
204
|
+
@options = legacy.merge(site.config['kramdown']).map { |key, value| [ key.to_sym, value ] }.to_h
|
205
|
+
end
|
206
|
+
|
207
|
+
def transform(content, page, options)
|
208
|
+
document = Kramdown::Document.new(content, @options)
|
209
|
+
options[:warnings].concat(document.warnings) if options[:warnings] && @options[:show_warnings]
|
210
|
+
document.to_html
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
class Layout < Glim::Filter
|
215
|
+
transforms '*' => 'output'
|
216
|
+
priority :lowest
|
217
|
+
|
218
|
+
def initialize(site)
|
219
|
+
@site, @options = site, options = {
|
220
|
+
:strict_variables => site.config['liquid']['strict_variables'],
|
221
|
+
:strict_filters => site.config['liquid']['strict_filters'],
|
222
|
+
}
|
223
|
+
end
|
224
|
+
|
225
|
+
def layouts
|
226
|
+
unless @layouts
|
227
|
+
@layouts = load_layouts(@site.layouts_dir)
|
228
|
+
if dir = @site.theme_dir('_layouts')
|
229
|
+
@layouts = load_layouts(dir, @site.theme_dir('..')).merge(@layouts)
|
230
|
+
end
|
231
|
+
end
|
232
|
+
@layouts
|
233
|
+
end
|
234
|
+
|
235
|
+
def load_layouts(dir, parent = File.dirname(dir))
|
236
|
+
layouts = {}
|
237
|
+
Util.find_files(dir, '**/*.*') do |path|
|
238
|
+
relative_basename = Util.relative_path(path, dir).chomp(File.extname(path))
|
239
|
+
layout = Glim::FileItem.new(@site, path, directory: parent)
|
240
|
+
layouts[relative_basename] = layout
|
241
|
+
end
|
242
|
+
layouts
|
243
|
+
end
|
244
|
+
|
245
|
+
def templates(name, file)
|
246
|
+
@templates ||= {}
|
247
|
+
@templates[name] ||= ::Liquid::Template.parse(Glim.preprocess_template(file.content('liquid')))
|
248
|
+
end
|
249
|
+
|
250
|
+
def transform(content, page, options)
|
251
|
+
return content if %w( .sass .scss .coffee ).include?(page.extname)
|
252
|
+
|
253
|
+
begin
|
254
|
+
layout_data = {}
|
255
|
+
layout_file = page
|
256
|
+
layout_name = page.data['layout']
|
257
|
+
Profiler.group(self.class.name + '::' + layout_file.data['layout']) do
|
258
|
+
while layout_file = self.layouts[layout_name]
|
259
|
+
layout_data.merge!(layout_file.data)
|
260
|
+
template = templates(layout_name, layout_file)
|
261
|
+
content = template.render!({ 'site' => @site.to_liquid, 'page' => page.to_liquid, 'layout' => HashDrop.new(layout_data), 'content' => content }, @options)
|
262
|
+
layout_name = layout_file.data['layout']
|
263
|
+
end
|
264
|
+
end if self.layouts.has_key?(layout_name)
|
265
|
+
rescue ::Liquid::Error => e
|
266
|
+
raise Glim::Error.new("While using layout: #{layout_file}", e)
|
267
|
+
end
|
268
|
+
content
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
class AssociativeArrayDrop < Liquid::Drop
|
274
|
+
include Enumerable
|
275
|
+
|
276
|
+
def initialize(hash, method = :last)
|
277
|
+
@hash, @values = hash, hash.to_a.sort { |a, b| a.first <=> b.first }.map { |a| a.send(method) }
|
278
|
+
end
|
279
|
+
|
280
|
+
def each(&block)
|
281
|
+
@values.each(&block)
|
282
|
+
end
|
283
|
+
|
284
|
+
def liquid_method_missing(name)
|
285
|
+
if @hash.key?(name)
|
286
|
+
@hash[name]
|
287
|
+
elsif name.is_a?(Numeric)
|
288
|
+
@values[name]
|
289
|
+
end
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
class HashDrop < Liquid::Drop
|
294
|
+
include Enumerable
|
295
|
+
|
296
|
+
def each(&block)
|
297
|
+
@hash.each(&block)
|
298
|
+
end
|
299
|
+
|
300
|
+
def liquid_method_missing(name)
|
301
|
+
@hash[name]
|
302
|
+
end
|
303
|
+
|
304
|
+
def initialize(hash)
|
305
|
+
@hash = hash
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
class Drop < Liquid::Drop
|
310
|
+
def liquid_method_missing(name)
|
311
|
+
res = if @whitelist.include?(name.to_sym)
|
312
|
+
@object.__send__(name.to_sym)
|
313
|
+
elsif @hash && @hash.key?(name)
|
314
|
+
@hash[name]
|
315
|
+
elsif @proc
|
316
|
+
@proc.call(name)
|
317
|
+
end
|
318
|
+
|
319
|
+
res.is_a?(Hash) ? HashDrop.new(res) : res
|
320
|
+
end
|
321
|
+
|
322
|
+
def initialize(object, whitelist, hash = {}, &proc)
|
323
|
+
@object, @whitelist, @hash, @proc = object, whitelist, hash, proc
|
324
|
+
end
|
325
|
+
end
|
326
|
+
|
327
|
+
# ============
|
328
|
+
# = FileItem =
|
329
|
+
# ============
|
330
|
+
|
331
|
+
class FileItem
|
332
|
+
attr_reader :path, :warnings
|
333
|
+
attr_accessor :next, :previous, :collection_object
|
334
|
+
|
335
|
+
def to_liquid
|
336
|
+
whitelist = [ :url, :path, :relative_path, :name, :title, :next, :previous, :collection, :date, :basename, :extname, :output, :excerpt ]
|
337
|
+
Drop.new(self, whitelist, self.data) do |key|
|
338
|
+
case key
|
339
|
+
when 'content' then self.content('pre-output')
|
340
|
+
when 'markdown' then self.content('markdown')
|
341
|
+
end
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
def to_s
|
346
|
+
self.link_path
|
347
|
+
end
|
348
|
+
|
349
|
+
def initialize(site, path = nil, directory: nil, content: nil, frontmatter: nil, defaults: {})
|
350
|
+
@site = site
|
351
|
+
@path = path
|
352
|
+
@directory = directory
|
353
|
+
@defaults = defaults
|
354
|
+
|
355
|
+
@content = content
|
356
|
+
@frontmatter = frontmatter
|
357
|
+
@has_frontmatter = frontmatter || content ? true : nil
|
358
|
+
end
|
359
|
+
|
360
|
+
def title
|
361
|
+
self.data['title'] || (@collection_object ? Util.titlecase(date_and_basename_without_ext.last) : nil)
|
362
|
+
end
|
363
|
+
|
364
|
+
def name
|
365
|
+
File.basename(@path) unless @path.nil?
|
366
|
+
end
|
367
|
+
|
368
|
+
def basename
|
369
|
+
File.basename(@path, '.*') unless @path.nil?
|
370
|
+
end
|
371
|
+
|
372
|
+
def extname
|
373
|
+
File.extname(@path) unless @path.nil?
|
374
|
+
end
|
375
|
+
|
376
|
+
def directory
|
377
|
+
@directory || (@collection_object ? @collection_object.directory : @site.source_dir) unless @path.nil?
|
378
|
+
end
|
379
|
+
|
380
|
+
def relative_path
|
381
|
+
@path && @path.start_with?('/') ? Util.relative_path(@path, self.directory) : @path
|
382
|
+
end
|
383
|
+
|
384
|
+
def link_path
|
385
|
+
Util.relative_path(@path, @collection_object ? File.dirname(@collection_object.directory) : self.directory) if @path
|
386
|
+
end
|
387
|
+
|
388
|
+
def collection
|
389
|
+
@collection_object ? @collection_object.label : nil
|
390
|
+
end
|
391
|
+
|
392
|
+
def date
|
393
|
+
@date ||= if date = Util.parse_date(self.data['date'])
|
394
|
+
date
|
395
|
+
elsif date = date_and_basename_without_ext.first
|
396
|
+
Time.new(*date.split('-'))
|
397
|
+
elsif @path && File.exists?(@path)
|
398
|
+
File.mtime(@path)
|
399
|
+
else
|
400
|
+
Time.now
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
def url
|
405
|
+
if @url.nil?
|
406
|
+
permalink = self.data['permalink']
|
407
|
+
if permalink.nil?
|
408
|
+
if self.basename == 'index'
|
409
|
+
permalink = '/:path/'
|
410
|
+
elsif self.output_ext == '.html'
|
411
|
+
permalink = '/:path/:basename'
|
412
|
+
else
|
413
|
+
permalink = '/:path/:basename:output_ext'
|
414
|
+
end
|
415
|
+
end
|
416
|
+
|
417
|
+
base = URI(@site.url)
|
418
|
+
scheme = self.data['scheme']
|
419
|
+
domain = self.data['domain']
|
420
|
+
path = expand_permalink(permalink)
|
421
|
+
|
422
|
+
@url = if domain && base.host == @site.config['host'] && base.port == @site.config['port']
|
423
|
+
base.merge('/' + domain + path).to_s
|
424
|
+
elsif base.relative?
|
425
|
+
path
|
426
|
+
else
|
427
|
+
base.hostname = domain unless domain.nil?
|
428
|
+
base.scheme = scheme unless scheme.nil?
|
429
|
+
base.merge(path).to_s
|
430
|
+
end
|
431
|
+
end
|
432
|
+
@url
|
433
|
+
end
|
434
|
+
|
435
|
+
def output_path(output_dir)
|
436
|
+
res = expand_permalink(self.data['permalink'] || '/:path/:basename:output_ext')
|
437
|
+
if res.end_with?('/')
|
438
|
+
res << 'index' << (self.output_ext || '.html')
|
439
|
+
elsif File.extname(res).empty? && self.data['permalink'] && self.output_ext
|
440
|
+
res << self.output_ext
|
441
|
+
end
|
442
|
+
File.expand_path(File.join(output_dir, self.data['domain'] || '.', res[1..-1]))
|
443
|
+
end
|
444
|
+
|
445
|
+
def output_ext
|
446
|
+
pipeline.output_ext(self.extname) || self.extname
|
447
|
+
end
|
448
|
+
|
449
|
+
# ==============
|
450
|
+
|
451
|
+
def format
|
452
|
+
self.data['format'] || pipeline.format_for_filename('liquid' + (self.extname || ''))
|
453
|
+
end
|
454
|
+
|
455
|
+
def frontmatter?
|
456
|
+
load_frontmatter
|
457
|
+
@has_frontmatter
|
458
|
+
end
|
459
|
+
|
460
|
+
def data
|
461
|
+
@data ||= @defaults.merge(load_frontmatter)
|
462
|
+
end
|
463
|
+
|
464
|
+
def merge_data!(data, source: "YAML front matter")
|
465
|
+
self.data.merge!(data)
|
466
|
+
@pipeline = nil
|
467
|
+
@excerpt = nil
|
468
|
+
@data
|
469
|
+
end
|
470
|
+
|
471
|
+
def write?
|
472
|
+
!@collection_object || @collection_object.write?
|
473
|
+
end
|
474
|
+
|
475
|
+
def content(format = 'post-liquid')
|
476
|
+
pipeline.transform(page: self, content: load_content, from: self.format, to: format, options: { :warnings => @warnings ||= [] })
|
477
|
+
end
|
478
|
+
|
479
|
+
def excerpt
|
480
|
+
@excerpt ||= self.data['excerpt']
|
481
|
+
if @excerpt.nil?
|
482
|
+
parts = content('post-liquid').split(@site.config['excerpt_separator'], 2)
|
483
|
+
if parts.size == 2
|
484
|
+
@excerpt = @site.create_pipeline.transform(page: self, content: parts.first, from: self.format, to: 'pre-output')
|
485
|
+
else
|
486
|
+
@excerpt = content('pre-output')
|
487
|
+
end
|
488
|
+
end
|
489
|
+
|
490
|
+
@excerpt
|
491
|
+
end
|
492
|
+
|
493
|
+
def output
|
494
|
+
content('output')
|
495
|
+
end
|
496
|
+
|
497
|
+
private
|
498
|
+
|
499
|
+
def load_frontmatter
|
500
|
+
if @has_frontmatter.nil? && @path
|
501
|
+
@frontmatter = Glim::Cache.getset(@path, :frontmatter) do
|
502
|
+
open(@path) do |io|
|
503
|
+
if io.read(4) == "---\n"
|
504
|
+
data = ''
|
505
|
+
while line = io.gets
|
506
|
+
break if line == "---\n"
|
507
|
+
data << line
|
508
|
+
end
|
509
|
+
data.strip.empty? ? {} : YAML.load(data)
|
510
|
+
else
|
511
|
+
nil
|
512
|
+
end
|
513
|
+
end
|
514
|
+
end
|
515
|
+
@has_frontmatter = @frontmatter != nil
|
516
|
+
end
|
517
|
+
@frontmatter ||= {}
|
518
|
+
end
|
519
|
+
|
520
|
+
def load_content
|
521
|
+
if @content.nil? && @path
|
522
|
+
open(@path) do |io|
|
523
|
+
@content = io.read
|
524
|
+
@content = @content.split(/^---\n/, 3).last if @content.start_with?("---\n")
|
525
|
+
end
|
526
|
+
end
|
527
|
+
@content ||= ''
|
528
|
+
end
|
529
|
+
|
530
|
+
def pipeline
|
531
|
+
@pipeline ||= @site.create_pipeline
|
532
|
+
end
|
533
|
+
|
534
|
+
def date_and_basename_without_ext
|
535
|
+
if self.basename && self.basename =~ /^(\d{2}\d{2}?-\d{1,2}-\d{1,2}-)?(.+)$/
|
536
|
+
Regexp.last_match.captures
|
537
|
+
else
|
538
|
+
[]
|
539
|
+
end
|
540
|
+
end
|
541
|
+
|
542
|
+
def expand_permalink(permalink)
|
543
|
+
permalink = case permalink
|
544
|
+
when 'date' then '/:categories/:year/:month/:day/:title:output_ext'
|
545
|
+
when 'pretty' then '/:categories/:year/:month/:day/:title/'
|
546
|
+
when 'ordinal' then '/:categories/:year/:y_day/:title:output_ext'
|
547
|
+
when 'none' then '/:categories/:title:output_ext'
|
548
|
+
else permalink
|
549
|
+
end
|
550
|
+
|
551
|
+
permalink.gsub(/\{:(\w+)\}|:(\w+)/) do
|
552
|
+
case $1 || $2
|
553
|
+
when 'title' then !self.frontmatter? ? File.basename(@path) : self.data['slug'] || Util.slugify(date_and_basename_without_ext.last, preserve_case: true)
|
554
|
+
when 'slug' then !self.frontmatter? ? File.basename(@path) : self.data['slug'] || Util.slugify(date_and_basename_without_ext.last)
|
555
|
+
when 'name' then !self.frontmatter? ? File.basename(@path) : Util.slugify(date_and_basename_without_ext.last)
|
556
|
+
when 'basename' then self.basename
|
557
|
+
|
558
|
+
when 'collection' then self.collection
|
559
|
+
when 'output_ext' then self.output_ext
|
560
|
+
|
561
|
+
when 'num' then self.data['paginator'].index
|
562
|
+
|
563
|
+
when 'digest' then Digest::MD5.hexdigest(self.output) rescue ''
|
564
|
+
|
565
|
+
when 'year' then self.date.strftime("%Y")
|
566
|
+
when 'month' then self.date.strftime("%m")
|
567
|
+
when 'day' then self.date.strftime("%d")
|
568
|
+
when 'hour' then self.date.strftime("%H")
|
569
|
+
when 'minute' then self.date.strftime("%M")
|
570
|
+
when 'second' then self.date.strftime("%S")
|
571
|
+
when 'i_day' then self.date.strftime("%-d")
|
572
|
+
when 'i_month' then self.date.strftime("%-m")
|
573
|
+
when 'short_month' then self.date.strftime("%b")
|
574
|
+
when 'short_year' then self.date.strftime("%y")
|
575
|
+
when 'y_day' then self.date.strftime("%j")
|
576
|
+
|
577
|
+
when 'categories' then
|
578
|
+
items = self.data['categories'].to_s
|
579
|
+
items = items.split(' ') if items.is_a?(String)
|
580
|
+
items.map { |category| Util.slugify(category) }.join('/')
|
581
|
+
|
582
|
+
when 'path' then
|
583
|
+
path = File.dirname(@path)
|
584
|
+
if path.start_with?('/')
|
585
|
+
path = Util.relative_path(path, File.expand_path(self.data['base_dir'] || '.', self.directory))
|
586
|
+
end
|
587
|
+
path == '.' ? '' : path
|
588
|
+
|
589
|
+
else
|
590
|
+
$log.warn("#{self}: Unknown permalink variable: ‘#{ $1 || $2 }’")
|
591
|
+
$&
|
592
|
+
end
|
593
|
+
end.gsub(%r{//+}, '/')
|
594
|
+
end
|
595
|
+
end
|
596
|
+
|
597
|
+
# ==============
|
598
|
+
# = Collection =
|
599
|
+
# ==============
|
600
|
+
|
601
|
+
class Collection
|
602
|
+
attr_reader :label, :directory, :docs, :files, :docs_and_files
|
603
|
+
|
604
|
+
def to_liquid
|
605
|
+
whitelist = [ :label, :docs, :files, :relative_directory, :directory, :output ]
|
606
|
+
Drop.new(self, whitelist) do |key|
|
607
|
+
case key
|
608
|
+
when 'categories' then AssociativeArrayDrop.new(categories, :first)
|
609
|
+
when 'tags' then AssociativeArrayDrop.new(tags, :first)
|
610
|
+
else @data[key]
|
611
|
+
end
|
612
|
+
end
|
613
|
+
end
|
614
|
+
|
615
|
+
def initialize(site, label, directory, docs_and_files, data)
|
616
|
+
docs_and_files.each { |file| file.collection_object = self }
|
617
|
+
|
618
|
+
published = lambda do |file|
|
619
|
+
file.frontmatter? &&
|
620
|
+
(site.config['show_drafts'] || file.data['draft'] != true) &&
|
621
|
+
(site.config['unpublished'] || file.data['published'] != false) &&
|
622
|
+
(site.config['future'] || file.date < Time.now)
|
623
|
+
end
|
624
|
+
docs = docs_and_files.select(&published)
|
625
|
+
files = docs_and_files.reject(&published)
|
626
|
+
|
627
|
+
sort_property = data['sort_by']
|
628
|
+
docs = docs.sort_by { |file| (sort_property && file.respond_to?(sort_property)) ? file.send(sort_property) : file.basename }
|
629
|
+
docs.each_cons(2) { |first, second| first.next, second.previous = second, first }
|
630
|
+
docs.reverse! if data['sort_descending']
|
631
|
+
|
632
|
+
@site = site
|
633
|
+
@label = label
|
634
|
+
@directory = directory
|
635
|
+
@docs, @files = docs, files
|
636
|
+
@docs_and_files = docs_and_files
|
637
|
+
@data = data
|
638
|
+
end
|
639
|
+
|
640
|
+
def generated_files(site)
|
641
|
+
res = []
|
642
|
+
%w( categories tags ).each do |type|
|
643
|
+
if permalink = @data.dig(type, 'permalink')
|
644
|
+
self.send(type.to_sym).each do |name, hash|
|
645
|
+
data = {}
|
646
|
+
data.merge!(site.defaults_for("", "#{@label}.#{type}"))
|
647
|
+
data.merge!(@data[type])
|
648
|
+
data.merge!({
|
649
|
+
'title' => name,
|
650
|
+
'slug' => Util.slugify(name),
|
651
|
+
'permalink' => permalink,
|
652
|
+
'posts' => hash['posts'],
|
653
|
+
})
|
654
|
+
|
655
|
+
res << Glim::FileItem.new(site, frontmatter: data).tap { |page| hash['url'] = page.url }
|
656
|
+
end
|
657
|
+
end
|
658
|
+
end
|
659
|
+
res
|
660
|
+
end
|
661
|
+
|
662
|
+
def write?
|
663
|
+
@data.has_key?('output') ? @data['output'] : %w( defaults categories ).any? { |key| @data.has_key?(key) }
|
664
|
+
end
|
665
|
+
|
666
|
+
def relative_directory
|
667
|
+
Util.relative_path(@directory, @site.source_dir)
|
668
|
+
end
|
669
|
+
|
670
|
+
def categories
|
671
|
+
@categories ||= harvest('category', 'categories')
|
672
|
+
end
|
673
|
+
|
674
|
+
def tags
|
675
|
+
@tags ||= harvest('tags')
|
676
|
+
end
|
677
|
+
|
678
|
+
private
|
679
|
+
|
680
|
+
def harvest(*fields)
|
681
|
+
res = {}
|
682
|
+
self.docs.each do |page|
|
683
|
+
fields.each do |field|
|
684
|
+
if values = page.data[field]
|
685
|
+
values = values.split(' ') if values.is_a?(String)
|
686
|
+
values.each do |value|
|
687
|
+
(res[value] ||= []) << page
|
688
|
+
end
|
689
|
+
end
|
690
|
+
end
|
691
|
+
end
|
692
|
+
res.map { |field_value, pages| [ field_value, { 'posts' => pages } ] }.to_h
|
693
|
+
end
|
694
|
+
end
|
695
|
+
|
696
|
+
# =============
|
697
|
+
# = Paginator =
|
698
|
+
# =============
|
699
|
+
|
700
|
+
class Paginator
|
701
|
+
attr_reader :posts
|
702
|
+
attr_accessor :pages, :index, :next, :previous, :first, :last
|
703
|
+
|
704
|
+
def to_liquid
|
705
|
+
whitelist = [ :posts, :pages, :index, :next, :previous, :first, :last ]
|
706
|
+
Drop.new(self, whitelist)
|
707
|
+
end
|
708
|
+
|
709
|
+
def initialize(posts, index)
|
710
|
+
@posts = posts
|
711
|
+
@index = index
|
712
|
+
end
|
713
|
+
end
|
714
|
+
|
715
|
+
# ========
|
716
|
+
# = Site =
|
717
|
+
# ========
|
718
|
+
|
719
|
+
class Site
|
720
|
+
attr_accessor :url, :time, :config
|
721
|
+
attr_reader :project_dir
|
722
|
+
|
723
|
+
class << self
|
724
|
+
def dir_reader(*dirs)
|
725
|
+
dirs.each do |dir|
|
726
|
+
define_method(dir) do
|
727
|
+
value = @config[dir.to_s] || '.'
|
728
|
+
if value.is_a?(Array)
|
729
|
+
value.map { |path| File.expand_path(path, self.project_dir) }
|
730
|
+
else
|
731
|
+
File.expand_path(value, self.project_dir)
|
732
|
+
end
|
733
|
+
end
|
734
|
+
end
|
735
|
+
end
|
736
|
+
end
|
737
|
+
|
738
|
+
dir_reader :source_dir, :collections_dir, :data_dir, :layouts_dir, :includes_dir, :plugins_dir
|
739
|
+
|
740
|
+
def to_liquid
|
741
|
+
whitelist = [ :url, :time, :data, :pages, :html_pages, :static_files, :html_files, :documents, :posts, :related_posts, :categories, :tags ]
|
742
|
+
Drop.new(self, whitelist) do |key|
|
743
|
+
if collections.key?(key)
|
744
|
+
collections[key].docs
|
745
|
+
elsif key == 'collections'
|
746
|
+
AssociativeArrayDrop.new(collections)
|
747
|
+
else
|
748
|
+
@config[key]
|
749
|
+
end
|
750
|
+
end
|
751
|
+
end
|
752
|
+
|
753
|
+
def initialize(options = {})
|
754
|
+
@config = options
|
755
|
+
|
756
|
+
@project_dir = File.expand_path(@config['source'])
|
757
|
+
@url = @config['url']
|
758
|
+
@time = Time.now
|
759
|
+
|
760
|
+
Liquid::Template.file_system = Glim::LocalFileSystem.new(self.includes_dir, self.theme_dir('_includes'))
|
761
|
+
Liquid::Template.error_mode = @config['liquid']['error_mode'].to_sym
|
762
|
+
|
763
|
+
Jekyll.sites = [ self ]
|
764
|
+
load_plugins
|
765
|
+
run_generators
|
766
|
+
end
|
767
|
+
|
768
|
+
def files
|
769
|
+
unless @files
|
770
|
+
@files = load_pages(self.source_dir, @config['include'], @config['exclude'])
|
771
|
+
if asset_dir = self.theme_dir('assets')
|
772
|
+
@files += load_pages(asset_dir, @config['include'], @config['exclude'], directory: File.dirname(asset_dir))
|
773
|
+
end
|
774
|
+
end
|
775
|
+
@files
|
776
|
+
end
|
777
|
+
|
778
|
+
def collections
|
779
|
+
@collections ||= load_collections(@config['collections'], self.collections_dir, @config['include'], @config['exclude'])
|
780
|
+
end
|
781
|
+
|
782
|
+
def data
|
783
|
+
@data ||= load_data(self.data_dir)
|
784
|
+
end
|
785
|
+
|
786
|
+
REDIRECT_TEMPLATE = <<~HTML
|
787
|
+
<!DOCTYPE html>
|
788
|
+
<html lang="en-US">
|
789
|
+
<meta charset="utf-8">
|
790
|
+
<title>Redirecting…</title>
|
791
|
+
<link rel="canonical" href="{{ page.redirect_to }}">
|
792
|
+
<script>location="{{ page.redirect_to }}"</script>
|
793
|
+
<meta http-equiv="refresh" content="0; url={{ page.redirect_to }}">
|
794
|
+
<meta name="robots" content="noindex">
|
795
|
+
<h1>Redirecting…</h1>
|
796
|
+
<a href="{{ page.redirect_to }}">Click here if you are not redirected.</a>
|
797
|
+
</html>
|
798
|
+
HTML
|
799
|
+
|
800
|
+
def generated_files
|
801
|
+
res = self.collections.map { |_, collection| collection.generated_files(self) }.flatten
|
802
|
+
|
803
|
+
domains = {}
|
804
|
+
|
805
|
+
files = [ *self.files, *self.documents ]
|
806
|
+
files.each do |file|
|
807
|
+
if redirect_from = file.data['redirect_from']
|
808
|
+
redirect_from = redirect_from.split(' ') if redirect_from.is_a?(String)
|
809
|
+
redirect_from.each do |path|
|
810
|
+
domain = file.data['domain'] || URI(self.url).host
|
811
|
+
domains[domain] ||= {}
|
812
|
+
domains[domain][path] = file.url
|
813
|
+
|
814
|
+
if path.end_with?('/')
|
815
|
+
path = path + file.name
|
816
|
+
elsif File.extname(path).empty?
|
817
|
+
path = path + file.extname
|
818
|
+
end
|
819
|
+
res << Glim::FileItem.new(self, frontmatter: { 'permalink' => path, 'domain' => file.data['domain'], 'redirect_to' => file.url }, content: REDIRECT_TEMPLATE)
|
820
|
+
end
|
821
|
+
$log.warn("Generated redirect to non-HTML file: #{file}") unless file.output_ext == '.html'
|
822
|
+
end
|
823
|
+
end
|
824
|
+
|
825
|
+
unless domains.empty?
|
826
|
+
res << Glim::FileItem.new(self, frontmatter: { 'permalink' => '/redirects.json' }, content: { 'domains' => domains }.to_json + "\n")
|
827
|
+
end
|
828
|
+
|
829
|
+
files.each do |file|
|
830
|
+
if paginate = file.data['paginate']
|
831
|
+
per_page = paginate['per_page'] || 25
|
832
|
+
permalink = paginate['permalink']
|
833
|
+
|
834
|
+
items = []
|
835
|
+
if key = paginate['collection']
|
836
|
+
$log.warn("No collection named #{key}") unless collections.key?(key)
|
837
|
+
items = collections[key].docs if collections.key?(key)
|
838
|
+
permalink ||= "/#{key}/page/:num/"
|
839
|
+
elsif key = paginate['data']
|
840
|
+
$log.warn("No data named #{key}") unless self.data.key?(key)
|
841
|
+
items = self.data[key] if self.data.key?(key)
|
842
|
+
permalink ||= "/#{key}/page/:num/"
|
843
|
+
end
|
844
|
+
|
845
|
+
if sort_property = paginate['sort_by']
|
846
|
+
items = items.sort_by do |item|
|
847
|
+
if item.is_a?(Hash)
|
848
|
+
item[sort_property]
|
849
|
+
elsif items.is_a?(Hash) && item.is_a?(Array) && item.last.is_a?(Hash)
|
850
|
+
item.last[sort_property]
|
851
|
+
elsif item.respond_to?(sort_property)
|
852
|
+
item.send(sort_property)
|
853
|
+
else
|
854
|
+
raise "Pagination failed for #{key} in #{file}: Unknown sort property: #{sort_property}"
|
855
|
+
end
|
856
|
+
end
|
857
|
+
elsif paginate['sort']
|
858
|
+
items = items.sort
|
859
|
+
end
|
860
|
+
items = items.reverse if paginate['sort_descending']
|
861
|
+
|
862
|
+
chunks = items.each_slice(per_page)
|
863
|
+
pages = chunks.each_with_index.map do |posts, i|
|
864
|
+
paginator = Paginator.new(posts, i + 1)
|
865
|
+
if i == 0
|
866
|
+
file.merge_data!({ 'paginator' => paginator })
|
867
|
+
file
|
868
|
+
else
|
869
|
+
clone = Glim::FileItem.new(self, file.path, defaults: file.data)
|
870
|
+
clone.merge_data!({ 'paginator' => paginator, 'permalink' => permalink })
|
871
|
+
clone
|
872
|
+
end
|
873
|
+
end
|
874
|
+
|
875
|
+
pages.each { |page| page.data['paginator'].pages = pages }
|
876
|
+
|
877
|
+
pages.each_cons(2) do |first, second|
|
878
|
+
first.data['paginator'].next, second.data['paginator'].previous = second, first
|
879
|
+
first.data['paginator'].last, second.data['paginator'].first = pages.last, pages.first
|
880
|
+
end
|
881
|
+
|
882
|
+
res += pages[1..-1] unless pages.empty?
|
883
|
+
end
|
884
|
+
end
|
885
|
+
|
886
|
+
res
|
887
|
+
end
|
888
|
+
|
889
|
+
def files_and_documents
|
890
|
+
@files_and_documents ||= [ *self.files, *self.documents, *self.generated_files ]
|
891
|
+
end
|
892
|
+
|
893
|
+
def symlinks
|
894
|
+
files_and_documents # Trigger dir scan
|
895
|
+
@symlinks
|
896
|
+
end
|
897
|
+
|
898
|
+
# ================================
|
899
|
+
# = Return a subset of all files =
|
900
|
+
# ================================
|
901
|
+
|
902
|
+
def pages
|
903
|
+
files.select { |file| file.frontmatter? }
|
904
|
+
end
|
905
|
+
|
906
|
+
def html_pages
|
907
|
+
pages.select { |page| page.extname == '.html' }
|
908
|
+
end
|
909
|
+
|
910
|
+
def static_files
|
911
|
+
files.reject { |file| file.frontmatter? }
|
912
|
+
end
|
913
|
+
|
914
|
+
def html_files
|
915
|
+
static_files.select { |page| page.extname == '.html' }
|
916
|
+
end
|
917
|
+
|
918
|
+
def documents
|
919
|
+
collections.map { |_, collection| collection.docs_and_files }.flatten
|
920
|
+
end
|
921
|
+
|
922
|
+
def posts
|
923
|
+
collections['posts'].docs if collections.key?('posts')
|
924
|
+
end
|
925
|
+
|
926
|
+
def related_posts # TODO
|
927
|
+
[]
|
928
|
+
end
|
929
|
+
|
930
|
+
def categories
|
931
|
+
collections['posts'].categories.map { |category, hash| [ category, hash['posts'] ] }.to_h if collections.key?('posts')
|
932
|
+
end
|
933
|
+
|
934
|
+
def tags
|
935
|
+
collections['posts'].tags.map { |tag, hash| [ tag, hash['posts'] ] }.to_h if collections.key?('posts')
|
936
|
+
end
|
937
|
+
|
938
|
+
# ============================
|
939
|
+
# = These are not public API =
|
940
|
+
# ============================
|
941
|
+
|
942
|
+
def links
|
943
|
+
if @links.nil?
|
944
|
+
transform = [
|
945
|
+
[ /([^\/]+)\.\w+$/, '\1' ],
|
946
|
+
[ /\/index\.\w+$/, '/' ],
|
947
|
+
[ /^index\.\w+$/, '.' ],
|
948
|
+
]
|
949
|
+
|
950
|
+
@links = [ *self.files, *self.documents ].map { |file| [ file.link_path, file ] }.to_h
|
951
|
+
@links.keys.each do |path|
|
952
|
+
transform.each do |search, replace|
|
953
|
+
shorthand = path.sub(search, replace)
|
954
|
+
@links[shorthand] = @links[path] unless @links.key?(shorthand)
|
955
|
+
end
|
956
|
+
end
|
957
|
+
end
|
958
|
+
@links
|
959
|
+
end
|
960
|
+
|
961
|
+
def post_links
|
962
|
+
@post_links ||= self.posts.map { |file| [ file.basename, file ] }.to_h
|
963
|
+
end
|
964
|
+
|
965
|
+
def theme_dir(subdir = '.')
|
966
|
+
unless @did_load_theme
|
967
|
+
@did_load_theme = true
|
968
|
+
|
969
|
+
if theme = @config['theme']
|
970
|
+
begin
|
971
|
+
if theme_gem = Gem::Specification.find_by_name(theme)
|
972
|
+
@theme_dir = theme_gem.full_gem_path
|
973
|
+
|
974
|
+
$log.debug("Theme dependencies: #{theme_gem.runtime_dependencies.join(', ')}")
|
975
|
+
theme_gem.runtime_dependencies.each do |dep|
|
976
|
+
Glim.require(dep.name)
|
977
|
+
end
|
978
|
+
|
979
|
+
if sass_dir = self.theme_dir('_sass')
|
980
|
+
Glim.require 'sass' unless defined?(::Sass)
|
981
|
+
Glim.require 'glim-sass-converter'
|
982
|
+
Sass.load_paths << sass_dir unless Sass.load_paths.include?(sass_dir)
|
983
|
+
end
|
984
|
+
end
|
985
|
+
rescue Gem::MissingSpecError => e
|
986
|
+
$log.warn("Unable to load the #{theme} theme: #{e}")
|
987
|
+
end
|
988
|
+
end
|
989
|
+
end
|
990
|
+
|
991
|
+
if @theme_dir && File.directory?(File.join(@theme_dir, subdir))
|
992
|
+
File.join(@theme_dir, subdir)
|
993
|
+
else
|
994
|
+
nil
|
995
|
+
end
|
996
|
+
end
|
997
|
+
|
998
|
+
def defaults_for(path, type)
|
999
|
+
data = {}
|
1000
|
+
@config['defaults'].each do |defaults|
|
1001
|
+
if match = defaults['match']
|
1002
|
+
next unless Array(match).any? do |glob|
|
1003
|
+
glob = glob + '**/{*,.*}' if glob.end_with?('/')
|
1004
|
+
File.fnmatch?(glob, path, File::FNM_PATHNAME|File::FNM_CASEFOLD|File::FNM_EXTGLOB)
|
1005
|
+
end
|
1006
|
+
end
|
1007
|
+
|
1008
|
+
if scope = defaults['scope']
|
1009
|
+
next if scope.key?('path') && !path.start_with?(scope['path'])
|
1010
|
+
next if scope.key?('type') && type != scope['type']
|
1011
|
+
|
1012
|
+
if globs = scope['glob']
|
1013
|
+
globs = [ globs ] if globs.is_a?(String)
|
1014
|
+
next unless globs.any? do |glob|
|
1015
|
+
glob = glob + '**/{*,.*}' if glob.end_with?('/')
|
1016
|
+
File.fnmatch?(glob, path, File::FNM_PATHNAME|File::FNM_CASEFOLD|File::FNM_EXTGLOB)
|
1017
|
+
end
|
1018
|
+
end
|
1019
|
+
end
|
1020
|
+
|
1021
|
+
data.merge!(defaults['values'])
|
1022
|
+
end
|
1023
|
+
data
|
1024
|
+
end
|
1025
|
+
|
1026
|
+
def create_pipeline
|
1027
|
+
@pipeline_builder ||= PipelineBuilder.new(self)
|
1028
|
+
Pipeline.new(@pipeline_builder)
|
1029
|
+
end
|
1030
|
+
|
1031
|
+
private
|
1032
|
+
|
1033
|
+
def matches?(path, globs)
|
1034
|
+
globs.find do |glob|
|
1035
|
+
File.fnmatch?(glob, path) || File.fnmatch?(glob, File.basename(path))
|
1036
|
+
end
|
1037
|
+
end
|
1038
|
+
|
1039
|
+
def run_generators
|
1040
|
+
Jekyll::Plugin.plugins_of_type(Jekyll::Generator).sort.each do |klass|
|
1041
|
+
Profiler.run("Letting #{klass} generate pages") do
|
1042
|
+
begin
|
1043
|
+
klass.new(@config).generate(self)
|
1044
|
+
rescue => e
|
1045
|
+
$log.error("Error running #{klass}#generate: #{e}")
|
1046
|
+
end
|
1047
|
+
end
|
1048
|
+
end
|
1049
|
+
end
|
1050
|
+
|
1051
|
+
def load_plugins
|
1052
|
+
@config['plugins'].each do |name|
|
1053
|
+
Profiler.run("Loading #{name} plugin") do
|
1054
|
+
Glim.require(name)
|
1055
|
+
end
|
1056
|
+
end
|
1057
|
+
|
1058
|
+
Array(self.plugins_dir).each do |dir|
|
1059
|
+
Util.find_files(dir, '**/*.rb') do |path|
|
1060
|
+
Profiler.run("Loading #{Util.relative_path(path, dir)} plugin") do
|
1061
|
+
Glim.require(path)
|
1062
|
+
end
|
1063
|
+
end
|
1064
|
+
end
|
1065
|
+
end
|
1066
|
+
|
1067
|
+
def load_pages(dir, include_globs, exclude_globs, relative_to = dir, collection = nil, defaults = {}, directory: nil)
|
1068
|
+
pages = []
|
1069
|
+
if File.directory?(dir)
|
1070
|
+
Find.find(dir) do |path|
|
1071
|
+
next if path == dir
|
1072
|
+
name = File.basename(path)
|
1073
|
+
relative_path = Util.relative_path(path, relative_to)
|
1074
|
+
dir_suffix = File.directory?(path) ? '/' : ''
|
1075
|
+
|
1076
|
+
Find.prune if name.start_with?('_')
|
1077
|
+
unless matches?(relative_path + dir_suffix, include_globs)
|
1078
|
+
Find.prune if name.start_with?('.') || matches?(relative_path + dir_suffix, exclude_globs)
|
1079
|
+
end
|
1080
|
+
|
1081
|
+
settings = defaults_for(relative_path, collection || 'pages').merge(defaults)
|
1082
|
+
|
1083
|
+
if File.symlink?(path)
|
1084
|
+
begin
|
1085
|
+
realpath = Pathname.new(path).realpath.to_s
|
1086
|
+
|
1087
|
+
if File.directory?(realpath)
|
1088
|
+
relative_path = Util.relative_path(path, settings['base_dir']) if settings.key?('base_dir')
|
1089
|
+
(@symlinks ||= []) << { :name => relative_path, :realpath => realpath, :data => settings }
|
1090
|
+
end
|
1091
|
+
rescue Errno::ENOENT
|
1092
|
+
$log.warn("No target for symbolic link: #{relative_path} → #{File.readlink(path)}")
|
1093
|
+
next
|
1094
|
+
end
|
1095
|
+
end
|
1096
|
+
|
1097
|
+
pages << Glim::FileItem.new(self, path, directory: directory || dir, defaults: settings) if File.file?(path)
|
1098
|
+
end
|
1099
|
+
end
|
1100
|
+
pages
|
1101
|
+
end
|
1102
|
+
|
1103
|
+
def load_collections(collections, dir, include_globs, exclude_globs)
|
1104
|
+
res = {}
|
1105
|
+
collections.each do |collection, data|
|
1106
|
+
collection_dir = File.expand_path("_#{collection}", dir)
|
1107
|
+
if File.directory?(collection_dir)
|
1108
|
+
Profiler.run("Loading #{collection} collection") do
|
1109
|
+
defaults = { 'permalink' => data['permalink'], 'base_dir' => collection_dir }
|
1110
|
+
defaults.merge!(data['defaults']) if data.key?('defaults')
|
1111
|
+
files = load_pages(collection_dir, include_globs, exclude_globs, dir, collection, defaults)
|
1112
|
+
if data.key?('drafts_dir')
|
1113
|
+
drafts_dir = File.expand_path(data['drafts_dir'], dir)
|
1114
|
+
defaults.merge!({ 'base_dir' => drafts_dir, 'draft' => true })
|
1115
|
+
files += load_pages(drafts_dir, include_globs, exclude_globs, dir, collection, defaults)
|
1116
|
+
end
|
1117
|
+
res[collection] = Collection.new(self, collection, collection_dir, files, data)
|
1118
|
+
end
|
1119
|
+
end
|
1120
|
+
end
|
1121
|
+
res
|
1122
|
+
end
|
1123
|
+
|
1124
|
+
def load_data(dir)
|
1125
|
+
data = {}
|
1126
|
+
Util.find_files(dir, '**/*.{yml,yaml,json,csv,tsv}') do |path|
|
1127
|
+
relative_basename = Util.relative_path(path, dir).chomp(File.extname(path))
|
1128
|
+
begin
|
1129
|
+
res = case File.extname(path).downcase
|
1130
|
+
when '.yaml', '.yml'
|
1131
|
+
YAML.load_file(path)
|
1132
|
+
when '.json'
|
1133
|
+
JSON.parse(File.read(path))
|
1134
|
+
when '.csv'
|
1135
|
+
CSV.read(path, { :headers => true }).map(&:to_hash)
|
1136
|
+
when ".tsv"
|
1137
|
+
CSV.read(path, { :headers => true, :col_sep => "\t" }).map(&:to_hash)
|
1138
|
+
end
|
1139
|
+
|
1140
|
+
*keys, last = relative_basename.split('/')
|
1141
|
+
keys.inject(data) { |hash, key| hash[key] ||= {} }[last] = res
|
1142
|
+
rescue => e
|
1143
|
+
$log.error("Error loading data file ‘#{path}’: #{e}")
|
1144
|
+
end
|
1145
|
+
end
|
1146
|
+
data
|
1147
|
+
end
|
1148
|
+
|
1149
|
+
class Pipeline
|
1150
|
+
def initialize(builder)
|
1151
|
+
@builder = builder
|
1152
|
+
end
|
1153
|
+
|
1154
|
+
def format_for_filename(filename)
|
1155
|
+
input_ext = File.extname(filename)
|
1156
|
+
from, _ = @builder.transformation_for_extension(input_ext)
|
1157
|
+
from ? (filename.chomp(input_ext) + '.' + from) : filename
|
1158
|
+
end
|
1159
|
+
|
1160
|
+
def output_ext(input_ext)
|
1161
|
+
_, to = @builder.transformation_for_extension(input_ext)
|
1162
|
+
to ? ('.' + to) : nil
|
1163
|
+
end
|
1164
|
+
|
1165
|
+
def transform(page: nil, content: nil, from: 'liquid', to: 'output', options: {})
|
1166
|
+
pipeline_for(from, to).inject(content) do |res, node|
|
1167
|
+
node.transform(res, page, options)
|
1168
|
+
end
|
1169
|
+
end
|
1170
|
+
|
1171
|
+
private
|
1172
|
+
|
1173
|
+
def pipeline_for(from_format, to_format)
|
1174
|
+
@pipeline ||= @builder.pipeline_for(from_format)
|
1175
|
+
|
1176
|
+
is_pre_match, is_post_match = to_format.start_with?('pre-'), to_format.start_with?('post-')
|
1177
|
+
is_full_match = !is_pre_match && !is_post_match
|
1178
|
+
|
1179
|
+
nodes = []
|
1180
|
+
@pipeline.each do |node|
|
1181
|
+
break if is_full_match && node.from_format.start_with?(to_format) && !node.to_format.start_with?(to_format)
|
1182
|
+
break if is_pre_match && node.to_format.start_with?(to_format[4..-1])
|
1183
|
+
nodes << node
|
1184
|
+
break if is_post_match && node.from_format.start_with?(to_format[5..-1]) && !node.to_format.start_with?(to_format[5..-1])
|
1185
|
+
end
|
1186
|
+
|
1187
|
+
nodes
|
1188
|
+
end
|
1189
|
+
end
|
1190
|
+
|
1191
|
+
class PipelineBuilder
|
1192
|
+
def initialize(site)
|
1193
|
+
temp = []
|
1194
|
+
|
1195
|
+
Jekyll::Plugin.plugins_of_type(Glim::Filter).each do |klass|
|
1196
|
+
filter = klass.new(site)
|
1197
|
+
filter.class.transforms.each do |from, to|
|
1198
|
+
temp << [ filter, from, to ]
|
1199
|
+
end
|
1200
|
+
end
|
1201
|
+
|
1202
|
+
@transformations = temp.sort_by do |filter, from, to|
|
1203
|
+
[ -filter.class.priority, (from.partition('.').first != to.partition('.').first ? +1 : -1) ]
|
1204
|
+
end
|
1205
|
+
|
1206
|
+
@cache = {}
|
1207
|
+
end
|
1208
|
+
|
1209
|
+
def transformation_for_extension(ext)
|
1210
|
+
Jekyll::Plugin.plugins_of_type(Glim::Filter).sort.each do |klass|
|
1211
|
+
klass.transforms.each do |from, to|
|
1212
|
+
next if from == '*' || to == '*' || from.partition('.').first == to.partition('.').first
|
1213
|
+
|
1214
|
+
input_extensions = if klass.extensions && klass.extensions.key?(from)
|
1215
|
+
klass.extensions[from]
|
1216
|
+
else
|
1217
|
+
[ from ]
|
1218
|
+
end
|
1219
|
+
|
1220
|
+
input_extensions.each do |input_ext|
|
1221
|
+
if ext == '.' + input_ext
|
1222
|
+
return [ from, to ]
|
1223
|
+
end
|
1224
|
+
end
|
1225
|
+
end if klass.transforms
|
1226
|
+
end
|
1227
|
+
nil
|
1228
|
+
end
|
1229
|
+
|
1230
|
+
def pipeline_for(from_format)
|
1231
|
+
(@cache[from_format] ||= create_pipeline(from_format)).map { |node| node.dup }
|
1232
|
+
end
|
1233
|
+
|
1234
|
+
def create_pipeline(format)
|
1235
|
+
result_nodes, transformations = [], @transformations.dup
|
1236
|
+
while transformation = transformations.find { |_, from, to| from == format || from == format.partition('.').first || (from == '*' && to.partition('.').first != format.partition('.').first) }
|
1237
|
+
filter, from, to = *transformation
|
1238
|
+
|
1239
|
+
format = if from == '*' && to == '*'
|
1240
|
+
format
|
1241
|
+
elsif from == '*'
|
1242
|
+
"#{to}.#{format}"
|
1243
|
+
elsif to == '*'
|
1244
|
+
format.partition('.').last
|
1245
|
+
else
|
1246
|
+
to
|
1247
|
+
end
|
1248
|
+
|
1249
|
+
result_nodes << PipelineNode.new(filter, from, to)
|
1250
|
+
transformations.delete(transformation)
|
1251
|
+
end
|
1252
|
+
result_nodes
|
1253
|
+
end
|
1254
|
+
|
1255
|
+
class PipelineNode
|
1256
|
+
attr_reader :from_format, :to_format
|
1257
|
+
|
1258
|
+
def initialize(filter, from_format, to_format)
|
1259
|
+
@filter, @from_format, @to_format = filter, from_format, to_format
|
1260
|
+
end
|
1261
|
+
|
1262
|
+
def dup
|
1263
|
+
PipelineNode.new(@filter, @from_format, @to_format)
|
1264
|
+
end
|
1265
|
+
|
1266
|
+
def transform(content, page, options)
|
1267
|
+
if @cache.nil?
|
1268
|
+
Profiler.group(@filter.class.name) do
|
1269
|
+
@cache = @filter.transform(content, page, options).freeze
|
1270
|
+
end
|
1271
|
+
end
|
1272
|
+
@cache
|
1273
|
+
end
|
1274
|
+
end
|
1275
|
+
end
|
1276
|
+
end
|
1277
|
+
|
1278
|
+
# ==========
|
1279
|
+
# = Config =
|
1280
|
+
# ==========
|
1281
|
+
|
1282
|
+
class Config
|
1283
|
+
def initialize(files: nil, defaults: nil, override: nil)
|
1284
|
+
@files = files || %w( _config.yml )
|
1285
|
+
@defaults = defaults || {}
|
1286
|
+
@override = override || {}
|
1287
|
+
end
|
1288
|
+
|
1289
|
+
def site
|
1290
|
+
@site ||= Glim::Site.new(self.to_h)
|
1291
|
+
end
|
1292
|
+
|
1293
|
+
def [](key)
|
1294
|
+
self.to_h[key]
|
1295
|
+
end
|
1296
|
+
|
1297
|
+
def []=(key, value)
|
1298
|
+
@override[key] = value
|
1299
|
+
@loaded[key] = value if @loaded
|
1300
|
+
end
|
1301
|
+
|
1302
|
+
def reload
|
1303
|
+
@loaded, @site = nil, nil
|
1304
|
+
end
|
1305
|
+
|
1306
|
+
def to_h
|
1307
|
+
@loaded ||= load_config
|
1308
|
+
end
|
1309
|
+
|
1310
|
+
private
|
1311
|
+
|
1312
|
+
DEFAULT_CONFIG = {
|
1313
|
+
# Where things are
|
1314
|
+
"source" => ".",
|
1315
|
+
"source_dir" => ".",
|
1316
|
+
"destination" => "_site",
|
1317
|
+
"collections_dir" => ".",
|
1318
|
+
"plugins_dir" => "_plugins",
|
1319
|
+
"layouts_dir" => "_layouts",
|
1320
|
+
"data_dir" => "_data",
|
1321
|
+
"includes_dir" => "_includes",
|
1322
|
+
"collections" => {
|
1323
|
+
"posts" => {
|
1324
|
+
"output" => true,
|
1325
|
+
"sort_by" => "date",
|
1326
|
+
"sort_descending" => true,
|
1327
|
+
"drafts_dir" => "_drafts",
|
1328
|
+
}
|
1329
|
+
},
|
1330
|
+
|
1331
|
+
# Handling Reading
|
1332
|
+
"include" => [".htaccess"],
|
1333
|
+
"exclude" => %w(
|
1334
|
+
Gemfile Gemfile.lock node_modules vendor/bundle/ vendor/cache/ vendor/gems/
|
1335
|
+
vendor/ruby/
|
1336
|
+
),
|
1337
|
+
"keep_files" => [".git", ".svn"],
|
1338
|
+
"encoding" => "utf-8",
|
1339
|
+
"markdown_ext" => "markdown,mkdown,mkdn,mkd,md",
|
1340
|
+
|
1341
|
+
# Filtering Content
|
1342
|
+
"future" => false,
|
1343
|
+
"unpublished" => false,
|
1344
|
+
|
1345
|
+
# Plugins
|
1346
|
+
"whitelist" => [],
|
1347
|
+
"plugins" => [],
|
1348
|
+
|
1349
|
+
# Conversion
|
1350
|
+
"highlighter" => "rouge",
|
1351
|
+
"excerpt_separator" => "\n\n",
|
1352
|
+
|
1353
|
+
# Serving
|
1354
|
+
"detach" => false, # default to not detaching the server
|
1355
|
+
"port" => 4000,
|
1356
|
+
"host" => "127.0.0.1",
|
1357
|
+
"show_dir_listing" => true,
|
1358
|
+
"livereload" => true,
|
1359
|
+
"livereload_port" => 35729,
|
1360
|
+
|
1361
|
+
# Output Configuration
|
1362
|
+
"permalink" => "date",
|
1363
|
+
"timezone" => nil, # use the local timezone
|
1364
|
+
|
1365
|
+
"quiet" => false,
|
1366
|
+
"verbose" => false,
|
1367
|
+
"defaults" => [],
|
1368
|
+
|
1369
|
+
"liquid" => {
|
1370
|
+
"error_mode" => "warn",
|
1371
|
+
"strict_filters" => false,
|
1372
|
+
"strict_variables" => false,
|
1373
|
+
},
|
1374
|
+
|
1375
|
+
"kramdown" => {
|
1376
|
+
"auto_ids" => true,
|
1377
|
+
"toc_levels" => "1..6",
|
1378
|
+
"entity_output" => "as_char",
|
1379
|
+
"smart_quotes" => "lsquo,rsquo,ldquo,rdquo",
|
1380
|
+
"input" => "GFM",
|
1381
|
+
"hard_wrap" => false,
|
1382
|
+
"footnote_nr" => 1,
|
1383
|
+
"show_warnings" => false,
|
1384
|
+
},
|
1385
|
+
}
|
1386
|
+
|
1387
|
+
def load_config
|
1388
|
+
initial = Util.deep_merge(DEFAULT_CONFIG, @defaults)
|
1389
|
+
initial = @files.inject(initial) do |mem, file|
|
1390
|
+
begin
|
1391
|
+
Util.deep_merge(mem, YAML.load_file(file))
|
1392
|
+
rescue => e
|
1393
|
+
raise "Unable to load #{file}: #{e}"
|
1394
|
+
end
|
1395
|
+
end
|
1396
|
+
initial = Util.deep_merge(initial, @override)
|
1397
|
+
initial['collections']['posts']['permalink'] ||= initial['permalink']
|
1398
|
+
initial
|
1399
|
+
end
|
1400
|
+
end
|
1401
|
+
|
1402
|
+
# ==================
|
1403
|
+
# = Custom Require =
|
1404
|
+
# ==================
|
1405
|
+
|
1406
|
+
REPLACEMENT_GEMS = {
|
1407
|
+
'jekyll' => nil,
|
1408
|
+
'jekyll-feed' => 'glim-feed',
|
1409
|
+
'jekyll-seo-tag' => 'glim-seo-tag',
|
1410
|
+
}
|
1411
|
+
|
1412
|
+
def self.require(name)
|
1413
|
+
@@loaded_gems ||= {}
|
1414
|
+
|
1415
|
+
unless @@loaded_gems.has_key?(name)
|
1416
|
+
@@loaded_gems[name] = false
|
1417
|
+
|
1418
|
+
begin
|
1419
|
+
@@loaded_gems[name] = true
|
1420
|
+
if REPLACEMENT_GEMS.key?(name)
|
1421
|
+
if replacement = REPLACEMENT_GEMS[name]
|
1422
|
+
$log.warn("Using #{replacement} instead of #{name}")
|
1423
|
+
Kernel.require replacement
|
1424
|
+
end
|
1425
|
+
else
|
1426
|
+
Kernel.require name
|
1427
|
+
end
|
1428
|
+
rescue LoadError => e
|
1429
|
+
$log.warn("Error loading ‘#{name}’: #{e}")
|
1430
|
+
end
|
1431
|
+
end
|
1432
|
+
|
1433
|
+
@@loaded_gems[name]
|
1434
|
+
end
|
1435
|
+
end
|
1436
|
+
|
1437
|
+
def add_program_options(cmd)
|
1438
|
+
cmd.option 'config', '--config CONFIG_FILE[,CONFIG_FILE2,...]', Array, 'Custom configuration file(s)'
|
1439
|
+
cmd.option 'source', '-s', '--source SOURCE', 'Custom source directory'
|
1440
|
+
cmd.option 'future', '--future', 'Publish posts with a future date'
|
1441
|
+
cmd.option 'show_drafts', '-D', '--drafts', 'Publish posts in draft folders'
|
1442
|
+
cmd.option 'unpublished', '--unpublished', 'Publish posts marked as unpublished'
|
1443
|
+
cmd.option 'quiet', '-q', '--quiet', 'Silence output'
|
1444
|
+
cmd.option 'verbose', '-V', '--verbose', 'Print verbose output'
|
1445
|
+
cmd.option 'plugins_dir', '-p', '--plugins PLUGINS_DIR1[,PLUGINS_DIR2[,...]]', Array, 'Custom plugins directory'
|
1446
|
+
cmd.option 'layouts_dir', '--layouts DIR', String, 'Custom layouts directory'
|
1447
|
+
end
|
1448
|
+
|
1449
|
+
Mercenary.program(:glim) do |p|
|
1450
|
+
p.version Glim::VERSION
|
1451
|
+
p.description 'Glim is a static site generator which is semi-compatible with Jekyll'
|
1452
|
+
p.syntax 'glim <subcommand> [options]'
|
1453
|
+
|
1454
|
+
p.command(:build) do |c|
|
1455
|
+
c.syntax 'build'
|
1456
|
+
c.description 'Build your site'
|
1457
|
+
c.alias :b
|
1458
|
+
c.option 'jobs', '-j', '--jobs N', Integer, 'Use N simultaneous jobs when building site'
|
1459
|
+
c.option 'destination', '-d', '--destination DESTINATION', 'Set destination directory for generated files'
|
1460
|
+
c.option 'profile', '--profile', 'Show timing information'
|
1461
|
+
add_program_options(c)
|
1462
|
+
|
1463
|
+
c.action do |args, options|
|
1464
|
+
Profiler.enabled = options['profile']
|
1465
|
+
|
1466
|
+
Glim::Commands.build(Glim::Config.new(files: options['config'], defaults: { 'environment' => 'production' }, override: options))
|
1467
|
+
|
1468
|
+
Profiler.run("Saving cache") do
|
1469
|
+
Glim::Cache.save
|
1470
|
+
end
|
1471
|
+
|
1472
|
+
Profiler.enabled = false
|
1473
|
+
end
|
1474
|
+
end
|
1475
|
+
|
1476
|
+
p.command(:clean) do |c|
|
1477
|
+
c.syntax 'clean'
|
1478
|
+
c.description 'Delete files in build directory'
|
1479
|
+
c.option 'destination', '-d', '--destination DESTINATION', 'Set the directory to clean'
|
1480
|
+
c.option 'dry_run', '--dry-run', 'Only show which files would be deleted'
|
1481
|
+
|
1482
|
+
c.action do |args, options|
|
1483
|
+
Glim::Commands.clean(Glim::Config.new(files: options['config'], defaults: { 'environment' => 'production' }, override: options))
|
1484
|
+
end
|
1485
|
+
end
|
1486
|
+
|
1487
|
+
p.command(:serve) do |c|
|
1488
|
+
c.syntax 'serve'
|
1489
|
+
c.description 'Serve your site locally'
|
1490
|
+
c.alias :s
|
1491
|
+
c.option 'open_url', '-o', '--open-url', 'Launch your site in a browser'
|
1492
|
+
c.option 'watch', '-w', '--[no-]watch', 'Watch for changes and rebuild'
|
1493
|
+
c.option 'livereload', '-l', '--[no-]livereload', 'Automatically send reload to browser on changes'
|
1494
|
+
c.option 'livereload_port', '--livereload-port [PORT]', Integer, 'Port to use for reload WebSocket server'
|
1495
|
+
add_program_options(c)
|
1496
|
+
|
1497
|
+
c.action do |args, options|
|
1498
|
+
require 'local_server'
|
1499
|
+
config = Glim::Config.new(files: options['config'], defaults: { 'environment' => 'development' }, override: options)
|
1500
|
+
Glim::LocalServer.start(config)
|
1501
|
+
end
|
1502
|
+
end
|
1503
|
+
|
1504
|
+
p.command(:profile) do |c|
|
1505
|
+
c.syntax 'profile'
|
1506
|
+
c.description 'Profile your site'
|
1507
|
+
add_program_options(c)
|
1508
|
+
c.action do |args, options|
|
1509
|
+
Glim::Commands.profile(Glim::Config.new(files: options['config'], defaults: { 'environment' => 'production' }, override: options))
|
1510
|
+
end
|
1511
|
+
end
|
1512
|
+
|
1513
|
+
Bundler.require(:glim_plugins)
|
1514
|
+
|
1515
|
+
Jekyll::Plugin.plugins_of_type(Jekyll::Command).sort.each do |klass|
|
1516
|
+
klass.init_with_program(p)
|
1517
|
+
end
|
1518
|
+
|
1519
|
+
p.command(:help) do |c|
|
1520
|
+
c.syntax 'help [subcommand]'
|
1521
|
+
c.description 'Show this help message, optionally for a given subcommand'
|
1522
|
+
|
1523
|
+
c.action do |args, _|
|
1524
|
+
if cmd = args.shift
|
1525
|
+
if p.has_command?(cmd.to_sym)
|
1526
|
+
STDOUT.puts "#{p.commands[cmd.to_sym]}\n"
|
1527
|
+
else
|
1528
|
+
STDOUT.puts "Error: No subcommand named ‘#{cmd}’.\n"
|
1529
|
+
STDOUT.puts "Valid commands are: #{p.commands.keys.join(', ')}\n"
|
1530
|
+
end
|
1531
|
+
else
|
1532
|
+
STDOUT.puts "#{p}\n"
|
1533
|
+
end
|
1534
|
+
end
|
1535
|
+
end
|
1536
|
+
|
1537
|
+
p.default_command(:help)
|
1538
|
+
end
|