glim 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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&hellip;</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&hellip;</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