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.
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