glim 0.1.1
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/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
|