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/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)