glim 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
data/lib/cache.rb ADDED
@@ -0,0 +1,66 @@
1
+ require 'fileutils'
2
+
3
+ module Glim
4
+ class Cache
5
+ CACHE_PATH = '.cache/glim/data.bin'
6
+
7
+ class << self
8
+ def load
9
+ cache
10
+ end
11
+
12
+ def save
13
+ unless @cache.nil?
14
+ FileUtils.mkdir_p(File.dirname(CACHE_PATH))
15
+ open(CACHE_PATH, 'w') do |io|
16
+ Marshal.dump(cache, io)
17
+ end
18
+ end
19
+ end
20
+
21
+ def track_updates=(flag)
22
+ @updates = flag ? {} : nil
23
+ end
24
+
25
+ def updates
26
+ @updates
27
+ end
28
+
29
+ def merge!(updates)
30
+ updates.each do |group, paths|
31
+ (cache[group] ||= {}).merge!(paths)
32
+ end
33
+ end
34
+
35
+ def getset(path, group = :default)
36
+ begin
37
+ mtime = File.stat(path).mtime
38
+ if record = cache.dig(group, path)
39
+ if mtime == record['modified']
40
+ return record['data']
41
+ end
42
+ end
43
+
44
+ record = {
45
+ 'modified' => mtime,
46
+ 'data' => yield,
47
+ }
48
+
49
+ (cache[group] ||= {})[path] = record
50
+ (@updates[group] ||= {})[path] = record if @updates
51
+
52
+ record['data']
53
+ rescue Errno::ENOENT
54
+ $log.warn("File does not exist: #{path}")
55
+ nil
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def cache
62
+ @cache ||= open(CACHE_PATH) { |io| Marshal.load(io) } rescue {}
63
+ end
64
+ end
65
+ end
66
+ end
data/lib/commands.rb ADDED
@@ -0,0 +1,261 @@
1
+ module Glim
2
+ module Commands
3
+ def self.build(config)
4
+ output_dir = File.expand_path(config['destination'])
5
+ files = config.site.files_and_documents.select { |file| file.write? }
6
+ symlinks = (config.site.symlinks || []).map { |link| [ File.expand_path(File.join(link[:data]['domain'] || '.', link[:name]), output_dir), link[:realpath] ] }.to_h
7
+
8
+ output_paths = files.map { |file| file.output_path(output_dir) }
9
+ output_paths.concat(symlinks.keys)
10
+
11
+ delete_files, delete_dirs = items_in_directory(output_dir, skip: config['keep_files'])
12
+ deleted = delete_items(delete_files, delete_dirs, keep: output_paths)
13
+ created, updated, warnings, errors = *generate(output_dir, config['jobs'] || 7, files)
14
+
15
+ symlinks.each do |dest, path|
16
+ FileUtils.mkdir_p(File.dirname(dest))
17
+ begin
18
+ File.symlink(path, dest)
19
+ created << dest
20
+ rescue Errno::EEXIST
21
+ if File.readlink(dest) != path
22
+ File.unlink(dest)
23
+ File.symlink(path, dest)
24
+ updated << dest
25
+ end
26
+ end
27
+ end
28
+
29
+ [ [ 'Created', created ], [ 'Deleted', deleted ], [ 'Updated', updated ] ].each do |label, files|
30
+ unless files.empty?
31
+ STDERR.puts "==> #{label} #{files.count} #{files.count == 1 ? 'File' : 'Files'}"
32
+ STDERR.puts files.map { |path| Util.relative_path(path, output_dir) }.sort.join(', ')
33
+ end
34
+ end
35
+
36
+ unless warnings.empty?
37
+ STDERR.puts "==> #{warnings.count} #{warnings.count == 1 ? 'Warnings' : 'Warning'}"
38
+ warnings.each do |message|
39
+ STDERR.puts message
40
+ end
41
+ end
42
+
43
+ unless errors.empty?
44
+ STDERR.puts "==> Stopped After #{errors.count} #{errors.count == 1 ? 'Error' : 'Errors'}"
45
+ errors.each do |arr|
46
+ arr.each_with_index do |err, i|
47
+ STDERR.puts err.gsub(/^/, ' '*i)
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ def self.clean(config)
54
+ files, dirs = items_in_directory(File.expand_path(config['destination']), skip: config['keep_files'])
55
+
56
+ if config['dry_run']
57
+ if files.empty?
58
+ STDOUT.puts "No files to delete"
59
+ else
60
+ files.each do |file|
61
+ STDOUT.puts "Delete #{Util.relative_path(file, File.expand_path(config['source']))}"
62
+ end
63
+ end
64
+ else
65
+ deleted = delete_items(files, dirs)
66
+ STDOUT.puts "Deleted #{deleted.count} #{deleted.count == 1 ? 'File' : 'Files'}."
67
+ end
68
+ end
69
+
70
+ def self.profile(config)
71
+ Profiler.enabled = true
72
+
73
+ site = Profiler.run("Setting up site") do
74
+ config.site
75
+ end
76
+
77
+ Profiler.run("Loading cache") do
78
+ Glim::Cache.load
79
+ end
80
+
81
+ files = []
82
+
83
+ Profiler.run("Loading pages") do
84
+ files.concat(site.files)
85
+ end
86
+
87
+ Profiler.run("Loading collections") do
88
+ files.concat(site.documents)
89
+ end
90
+
91
+ Profiler.run("Generating virtual pages") do
92
+ files.concat(site.generated_files)
93
+ end
94
+
95
+ files = files.select { |file| file.frontmatter? }
96
+
97
+ Profiler.run("Expanding liquid tags") do
98
+ files.each { |file| file.content('post-liquid') }
99
+ end
100
+
101
+ Profiler.run("Transforming pages") do
102
+ files.each { |file| file.content('pre-output') }
103
+ end
104
+
105
+ Profiler.run("Creating final output (layout)") do
106
+ files.each { |file| file.output }
107
+ end
108
+
109
+ Profiler.enabled = false
110
+ end
111
+
112
+ # ===========
113
+ # = Private =
114
+ # ===========
115
+
116
+ def self.items_in_directory(dir, skip: [])
117
+ files, dirs = [], []
118
+
119
+ begin
120
+ Find.find(dir) do |path|
121
+ next if path == dir
122
+ Find.prune if skip.include?(File.basename(path))
123
+
124
+ if File.file?(path) || File.symlink?(path)
125
+ files << path
126
+ elsif File.directory?(path)
127
+ dirs << path
128
+ else
129
+ $log.warn("Unknown entry: #{path}")
130
+ end
131
+ end
132
+ rescue Errno::ENOENT
133
+ end
134
+
135
+ [ files, dirs ]
136
+ end
137
+
138
+ def self.delete_items(files, dirs, keep: [])
139
+ res = []
140
+
141
+ keep_files = Set.new(keep)
142
+ files.each do |path|
143
+ unless keep_files.include?(path)
144
+ begin
145
+ File.unlink(path)
146
+ res << path
147
+ rescue => e
148
+ $log.error("Error unlinking ‘#{path}’: #{e}\n")
149
+ end
150
+ end
151
+ end
152
+
153
+ dirs.sort.reverse.each do |path|
154
+ begin
155
+ Dir.rmdir(path)
156
+ rescue Errno::ENOTEMPTY => e
157
+ # Ignore
158
+ rescue => e
159
+ $log.error("Error removing directory ‘#{path}’: #{e}\n")
160
+ end
161
+ end
162
+
163
+ res
164
+ end
165
+
166
+ def self.generate(output_dir, number_of_jobs, files)
167
+ Profiler.run("Creating pages") do
168
+ if number_of_jobs == 1
169
+ generate_subset(output_dir, files)
170
+ else
171
+ generate_async(output_dir, files.shuffle, number_of_jobs)
172
+ end
173
+ end
174
+ end
175
+
176
+ def self.generate_async(output_dir, files, number_of_jobs)
177
+ total = files.size
178
+ slices = number_of_jobs.times.map do |i|
179
+ first = (total * i / number_of_jobs).ceil
180
+ last = (total * (i+1) / number_of_jobs).ceil
181
+ files.shift(last-first)
182
+ end
183
+
184
+ Glim::Cache.track_updates = true
185
+ semaphore = Mutex.new
186
+ created, updated, warnings, errors = [], [], [], []
187
+
188
+ threads = slices.each_with_index.map do |files_slice, i|
189
+ pipe_rd, pipe_wr = IO.pipe
190
+ pid = fork do
191
+ start = Time.now
192
+ pipe_rd.close
193
+ created, updated, warnings, errors = *generate_subset(output_dir, files_slice)
194
+ pipe_wr << Marshal.dump({
195
+ 'cache_updates' => Glim::Cache.updates,
196
+ 'created' => created,
197
+ 'updated' => updated,
198
+ 'warnings' => warnings,
199
+ 'errors' => errors,
200
+ 'duration' => Time.now - start,
201
+ 'id' => i,
202
+ })
203
+ pipe_wr.close
204
+ end
205
+
206
+ Process.detach(pid)
207
+
208
+ Thread.new do
209
+ pipe_wr.close
210
+ res = Marshal.load(pipe_rd)
211
+ semaphore.synchronize do
212
+ Glim::Cache.merge!(res['cache_updates'])
213
+ created += res['created']
214
+ updated += res['updated']
215
+ warnings += res['warnings']
216
+ errors += res['errors']
217
+ $log.debug("Wrote #{files_slice.size} pages in #{res['duration']} seconds (thread #{res['id']})") if Profiler.enabled
218
+ end
219
+ end
220
+ end
221
+
222
+ threads.each { |thread| thread.join }
223
+
224
+ [ created, updated, warnings, errors ]
225
+ end
226
+
227
+ def self.generate_subset(output_dir, files)
228
+ created, updated, warnings, errors = [], [], [], []
229
+
230
+ for file in files do
231
+ dest = file.output_path(output_dir)
232
+ file_exists = File.exists?(dest)
233
+
234
+ FileUtils.mkdir_p(File.dirname(dest))
235
+ if file.frontmatter?
236
+ begin
237
+ if !file_exists || File.read(dest) != file.output
238
+ (file_exists ? updated : created) << dest
239
+ File.unlink(dest) if file_exists
240
+ File.write(dest, file.output)
241
+ end
242
+ warnings.concat(file.warnings.map { |warning| "#{file}: #{warning}" }) unless file.warnings.nil?
243
+ rescue Glim::Error => e
244
+ errors << [ "Unable to create output for: #{file}", *e.messages ]
245
+ break
246
+ rescue => e
247
+ errors << [ "Unable to create output for: #{file}", e.to_s, e.backtrace.join("\n") ]
248
+ break
249
+ end
250
+ else
251
+ unless File.file?(dest) && File.file?(file.path) && File.stat(dest).ino == File.stat(file.path).ino
252
+ File.unlink(dest) if file_exists
253
+ File.link(file.path, dest)
254
+ end
255
+ end
256
+ end
257
+
258
+ [ created, updated, warnings, errors ]
259
+ end
260
+ end
261
+ end
data/lib/exception.rb ADDED
@@ -0,0 +1,18 @@
1
+ module Glim
2
+ class Error < ::RuntimeError
3
+ attr_reader :message, :previous
4
+
5
+ def initialize(message, previous = nil)
6
+ @message, @previous = message, previous
7
+ end
8
+
9
+ def messages
10
+ res = [ @message ]
11
+ e = self
12
+ while e.respond_to?(:previous) && (e = e.previous)
13
+ res << e.message
14
+ end
15
+ res
16
+ end
17
+ end
18
+ end
data/lib/liquid_ext.rb ADDED
@@ -0,0 +1,249 @@
1
+ require 'kramdown'
2
+ require 'liquid'
3
+
4
+ module Glim
5
+ module LiquidFilters
6
+ def markdownify(input)
7
+ Profiler.group('markdownify') do
8
+ if defined?(MultiMarkdown)
9
+ MultiMarkdown.new("\n" + input, 'snippet', 'no_metadata').to_html
10
+ else
11
+ options = @context['site']['kramdown'].map { |key, value| [ key.to_sym, value ] }.to_h
12
+ document = Kramdown::Document.new(input, options)
13
+ @context['warnings'].concat(document.warnings) if options[:show_warnings] && @context['warnings']
14
+ document.to_html
15
+ end unless input.nil?
16
+ end
17
+ end
18
+
19
+ def slugify(input)
20
+ Util.slugify(input) unless input.nil?
21
+ end
22
+
23
+ def xml_escape(input)
24
+ input.encode(:xml => :attr).gsub(/\A"|"\z/, '') unless input.nil?
25
+ end
26
+
27
+ def cgi_escape(input)
28
+ CGI.escape(input) unless input.nil?
29
+ end
30
+
31
+ def absolute_url(path)
32
+ unless path.nil?
33
+ site, page = URI(@context['site']['url']), URI(@context['page']['url'])
34
+ host, port = @context['site']['host'], @context['site']['port']
35
+ if page.relative? || (site.host == host && site.port == port)
36
+ site.merge(URI(path)).to_s
37
+ else
38
+ page.merge(URI(path)).to_s
39
+ end
40
+ end
41
+ end
42
+
43
+ def relative_url(other)
44
+ helper = lambda do |base, other|
45
+ base_url, other_url = URI(base), URI(other)
46
+ if other_url.absolute? && base_url.host == other_url.host
47
+ other_url.path
48
+ else
49
+ other
50
+ end
51
+ end
52
+
53
+ unless other.nil?
54
+ site, page = URI(@context['site']['url']), URI(@context['page']['url'])
55
+ host, port = @context['site']['host'], @context['site']['port']
56
+ if page.relative? || (site.host == host && site.port == port)
57
+ helper.call(@context['site']['url'], other)
58
+ else
59
+ helper.call(@context['page']['url'], other)
60
+ end
61
+ end
62
+ end
63
+
64
+ def path_to_url(input)
65
+ if file = Jekyll.sites.last.links[input]
66
+ file.url
67
+ else
68
+ raise Glim::Error.new("path_to_url: No file found for: #{input}")
69
+ end unless input.nil?
70
+ end
71
+
72
+ def date_to_xmlschema(input)
73
+ Liquid::Utils.to_date(input).localtime.xmlschema unless input.nil?
74
+ end
75
+
76
+ def date_to_rfc822(input)
77
+ Liquid::Utils.to_date(input).localtime.rfc822 unless input.nil?
78
+ end
79
+
80
+ def date_to_string(input)
81
+ Liquid::Utils.to_date(input).localtime.strftime("%d %b %Y") unless input.nil?
82
+ end
83
+
84
+ def date_to_long_string(input)
85
+ Liquid::Utils.to_date(input).localtime.strftime("%d %B %Y") unless input.nil?
86
+ end
87
+
88
+ def where(input, property, value)
89
+ if input.respond_to?(:select) && property && value
90
+ input = input.values if input.is_a?(Hash)
91
+ input.select { |item| get_property(item, property) == value }
92
+ else
93
+ input
94
+ end
95
+ end
96
+
97
+ def group_by(input, property)
98
+ if input.respond_to?(:group_by) && property
99
+ groups = input.group_by { |item| get_property(item, property) }
100
+ groups.map { |key, value| { "name" => key, "items" => value, "size" => value.size } }
101
+ else
102
+ input
103
+ end
104
+ end
105
+
106
+ def group_by_exp(input, variable, expression)
107
+ if input.respond_to?(:group_by)
108
+ parsed_expr = Liquid::Variable.new(expression, Liquid::ParseContext.new)
109
+ @context.stack do
110
+ groups = input.group_by do |item|
111
+ @context[variable] = item
112
+ parsed_expr.render(@context)
113
+ end
114
+ groups.map { |key, value| { "name" => key, "items" => value, "size" => value.size } }
115
+ end
116
+ else
117
+ input
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def get_property(obj, property)
124
+ if obj.respond_to?(:to_liquid)
125
+ property.to_s.split('.').reduce(obj.to_liquid) do |mem, key|
126
+ mem[key]
127
+ end
128
+ elsif obj.respond_to?(:data)
129
+ obj.data[property.to_s]
130
+ else
131
+ obj[property.to_s]
132
+ end
133
+ end
134
+ end
135
+
136
+ module LiquidTags
137
+ class PostURL < Liquid::Tag
138
+ def initialize(tag_name, markup, options)
139
+ super
140
+ @post_name = markup.strip
141
+ end
142
+
143
+ def render(context)
144
+ if file = Jekyll.sites.last.post_links[@post_name]
145
+ file.url
146
+ else
147
+ raise Glim::Error.new("post_url: No post found for: #{@post_name}")
148
+ end
149
+ end
150
+ end
151
+
152
+ class Link < Liquid::Tag
153
+ def initialize(tag_name, markup, options)
154
+ super
155
+ @relative_path = markup.strip
156
+ end
157
+
158
+ def render(context)
159
+ if file = Jekyll.sites.last.links[@relative_path]
160
+ file.url
161
+ else
162
+ raise Glim::Error.new("link: No file found for: #{@relative_path}")
163
+ end
164
+ end
165
+ end
166
+
167
+ class HighlightBlock < Liquid::Block
168
+ def initialize(tag_name, markup, tokens)
169
+ super
170
+
171
+ if markup =~ /^([a-zA-Z0-9.+#_-]+)((\s+\w+(=(\w+|"[^"]*"))?)*)\s*$/
172
+ @language, @options = $1, $2.scan(/(\w+)(?:=(?:(\w+)|"([^"]*)"))?/).map do |key, value, list|
173
+ [ key.to_sym, list ? list.split : (value || true) ]
174
+ end.to_h
175
+ else
176
+ @language, @options = nil, {}
177
+ $log.error("Unable to parse highlight tag: #{markup}") unless markup.strip.empty?
178
+ end
179
+
180
+ begin
181
+ require 'rouge'
182
+ rescue LoadError => e
183
+ $log.warn("Unable to load the rouge gem required by the highlight tag: #{e}")
184
+ end
185
+ end
186
+
187
+ def render(context)
188
+ source = super.to_s.gsub(/\A[\r\n]+|[\r\n]+\z/, '')
189
+
190
+ if defined?(Rouge)
191
+ rouge_options = {
192
+ :line_numbers => @options[:linenos] == true ? 'inline' : @options[:linenos],
193
+ :wrap => false,
194
+ :css_class => 'highlight',
195
+ :gutter_class => 'gutter',
196
+ :code_class => 'code'
197
+ }.merge(@options)
198
+
199
+ lexer = Rouge::Lexer.find_fancy(@language, source) || Rouge::Lexers::PlainText
200
+ formatter = Rouge::Formatters::HTMLLegacy.new(rouge_options)
201
+ source = formatter.format(lexer.lex(source))
202
+
203
+ $log.warn("No language specified in highlight tag. Will use #{lexer.class.name} to parse the code.") if @language.nil?
204
+ end
205
+
206
+ code_attributes = @language ? " class=\"language-#{@language.tr('+', '-')}\" data-lang=\"#{@language}\"" : ""
207
+ "<figure class=\"highlight\"><pre><code#{code_attributes}>#{source.chomp}</code></pre></figure>"
208
+ end
209
+ end
210
+ end
211
+
212
+ def self.preprocess_template(source)
213
+ source = source.gsub(/({%-? include )([\w.\/-]+)(.*?)(-?%})/) do
214
+ prefix, include, variables, suffix = $1, $2, $3, $4
215
+ unless variables.strip.empty?
216
+ variables = ', ' + variables.scan(/(\w+)=(.*?)(?=\s)/).map { |key, value| "include_#{key}: #{value}" }.join(', ') + ' '
217
+ end
218
+
219
+ "#{prefix}\"#{include}\"#{variables}#{suffix}"
220
+ end
221
+
222
+ source.gsub!(/({{-? include)\.(.*?}})/) { "#$1_#$2" }
223
+ source.gsub!(/({%-? .+? include)\.(.*?%})/) { "#$1_#$2" }
224
+
225
+ source
226
+ end
227
+
228
+ class LocalFileSystem
229
+ def initialize(*paths)
230
+ @paths = paths.reject { |path| path.nil? }
231
+ end
232
+
233
+ def read_template_file(name)
234
+ @cache ||= {}
235
+ unless @cache[name]
236
+ paths = @paths.map { |path| File.join(path, name) }
237
+ if file = paths.find { |path| File.exist?(path) }
238
+ @cache[name] = Glim.preprocess_template(File.read(file))
239
+ end
240
+ end
241
+ @cache[name]
242
+ end
243
+ end
244
+ end
245
+
246
+ Liquid::Template.register_filter(Glim::LiquidFilters)
247
+ Liquid::Template.register_tag('post_url', Glim::LiquidTags::PostURL)
248
+ Liquid::Template.register_tag('link', Glim::LiquidTags::Link)
249
+ Liquid::Template.register_tag("highlight", Glim::LiquidTags::HighlightBlock)