moft 1.0.0

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.
Files changed (48) hide show
  1. data/Gemfile +2 -0
  2. data/LICENSE +21 -0
  3. data/bin/moft +83 -0
  4. data/lib/moft.rb +89 -0
  5. data/lib/moft/command.rb +27 -0
  6. data/lib/moft/commands/build.rb +64 -0
  7. data/lib/moft/commands/new.rb +50 -0
  8. data/lib/moft/commands/serve.rb +33 -0
  9. data/lib/moft/configuration.rb +171 -0
  10. data/lib/moft/converter.rb +48 -0
  11. data/lib/moft/converters/identity.rb +21 -0
  12. data/lib/moft/converters/markdown.rb +43 -0
  13. data/lib/moft/converters/markdown/kramdown_parser.rb +44 -0
  14. data/lib/moft/converters/markdown/maruku_parser.rb +47 -0
  15. data/lib/moft/converters/markdown/rdiscount_parser.rb +26 -0
  16. data/lib/moft/converters/markdown/redcarpet_parser.rb +40 -0
  17. data/lib/moft/converters/textile.rb +50 -0
  18. data/lib/moft/convertible.rb +152 -0
  19. data/lib/moft/core_ext.rb +68 -0
  20. data/lib/moft/deprecator.rb +34 -0
  21. data/lib/moft/draft.rb +35 -0
  22. data/lib/moft/errors.rb +4 -0
  23. data/lib/moft/filters.rb +141 -0
  24. data/lib/moft/generator.rb +4 -0
  25. data/lib/moft/generators/pagination.rb +131 -0
  26. data/lib/moft/layout.rb +42 -0
  27. data/lib/moft/logger.rb +52 -0
  28. data/lib/moft/mime.types +85 -0
  29. data/lib/moft/page.rb +147 -0
  30. data/lib/moft/plugin.rb +75 -0
  31. data/lib/moft/post.rb +377 -0
  32. data/lib/moft/site.rb +422 -0
  33. data/lib/moft/static_file.rb +70 -0
  34. data/lib/moft/tags/gist.rb +30 -0
  35. data/lib/moft/tags/highlight.rb +83 -0
  36. data/lib/moft/tags/include.rb +37 -0
  37. data/lib/moft/tags/post_url.rb +46 -0
  38. data/lib/site_template/_config.yml +1 -0
  39. data/lib/site_template/_layouts/default.html +38 -0
  40. data/lib/site_template/_layouts/post.html +6 -0
  41. data/lib/site_template/_posts/0000-00-00-welcome-to-jekyll.markdown.erb +24 -0
  42. data/lib/site_template/css/screen.css +189 -0
  43. data/lib/site_template/css/syntax.css +60 -0
  44. data/lib/site_template/images/.gitkeep +0 -0
  45. data/lib/site_template/images/rss.png +0 -0
  46. data/lib/site_template/index.html +13 -0
  47. data/moft.gemspec +100 -0
  48. metadata +412 -0
@@ -0,0 +1,422 @@
1
+ require 'set'
2
+
3
+ module Moft
4
+ class Site
5
+ attr_accessor :config, :layouts, :posts, :pages, :static_files,
6
+ :categories, :exclude, :include, :source, :dest, :lsi, :pygments,
7
+ :permalink_style, :tags, :time, :future, :safe, :plugins, :limit_posts,
8
+ :show_drafts, :keep_files, :baseurl
9
+
10
+ attr_accessor :converters, :generators
11
+
12
+ # Public: Initialize a new Site.
13
+ #
14
+ # config - A Hash containing site configuration details.
15
+ def initialize(config)
16
+ self.config = config.clone
17
+
18
+ self.safe = config['safe']
19
+ self.source = File.expand_path(config['source'])
20
+ self.dest = File.expand_path(config['destination'])
21
+ self.plugins = plugins_path
22
+ self.lsi = config['lsi']
23
+ self.pygments = config['pygments']
24
+ self.baseurl = config['baseurl']
25
+ self.permalink_style = config['permalink'].to_sym
26
+ self.exclude = config['exclude']
27
+ self.include = config['include']
28
+ self.future = config['future']
29
+ self.show_drafts = config['show_drafts']
30
+ self.limit_posts = config['limit_posts']
31
+ self.keep_files = config['keep_files']
32
+
33
+ self.reset
34
+ self.setup
35
+ end
36
+
37
+ # Public: Read, process, and write this Site to output.
38
+ #
39
+ # Returns nothing.
40
+ def process
41
+ self.reset
42
+ self.read
43
+ self.generate
44
+ self.render
45
+ self.cleanup
46
+ self.write
47
+ end
48
+
49
+ # Reset Site details.
50
+ #
51
+ # Returns nothing
52
+ def reset
53
+ self.time = if self.config['time']
54
+ Time.parse(self.config['time'].to_s)
55
+ else
56
+ Time.now
57
+ end
58
+ self.layouts = {}
59
+ self.posts = []
60
+ self.pages = []
61
+ self.static_files = []
62
+ self.categories = Hash.new { |hash, key| hash[key] = [] }
63
+ self.tags = Hash.new { |hash, key| hash[key] = [] }
64
+
65
+ if self.limit_posts < 0
66
+ raise ArgumentError, "limit_posts must not be a negative number"
67
+ end
68
+ end
69
+
70
+ # Load necessary libraries, plugins, converters, and generators.
71
+ #
72
+ # Returns nothing.
73
+ def setup
74
+ # require 'classifier' if self.lsi
75
+
76
+ # Check that the destination dir isn't the source dir or a directory
77
+ # parent to the source dir.
78
+ if self.source =~ /^#{self.dest}/
79
+ raise FatalException.new "Destination directory cannot be or contain the Source directory."
80
+ end
81
+
82
+ # If safe mode is off, load in any Ruby files under the plugins
83
+ # directory.
84
+ unless self.safe
85
+ self.plugins.each do |plugins|
86
+ Dir[File.join(plugins, "**/*.rb")].each do |f|
87
+ require f
88
+ end
89
+ end
90
+ end
91
+
92
+ self.converters = instantiate_subclasses(Moft::Converter)
93
+ self.generators = instantiate_subclasses(Moft::Generator)
94
+ end
95
+
96
+ # Internal: Setup the plugin search path
97
+ #
98
+ # Returns an Array of plugin search paths
99
+ def plugins_path
100
+ if (config['plugins'] == Moft::Configuration::DEFAULTS['plugins'])
101
+ [File.join(self.source, config['plugins'])]
102
+ else
103
+ Array(config['plugins']).map { |d| File.expand_path(d) }
104
+ end
105
+ end
106
+
107
+ # Read Site data from disk and load it into internal data structures.
108
+ #
109
+ # Returns nothing.
110
+ def read
111
+ self.read_layouts
112
+ self.read_directories
113
+ end
114
+
115
+ # Read all the files in <source>/<layouts> and create a new Layout object
116
+ # with each one.
117
+ #
118
+ # Returns nothing.
119
+ def read_layouts
120
+ base = File.join(self.source, self.config['layouts'])
121
+ return unless File.exists?(base)
122
+ entries = []
123
+ Dir.chdir(base) { entries = filter_entries(Dir['*.*']) }
124
+
125
+ entries.each do |f|
126
+ name = f.split(".")[0..-2].join(".")
127
+ self.layouts[name] = Layout.new(self, base, f)
128
+ end
129
+ end
130
+
131
+ # Recursively traverse directories to find posts, pages and static files
132
+ # that will become part of the site according to the rules in
133
+ # filter_entries.
134
+ #
135
+ # dir - The String relative path of the directory to read. Default: ''.
136
+ #
137
+ # Returns nothing.
138
+ def read_directories(dir = '')
139
+ base = File.join(self.source, dir)
140
+ entries = Dir.chdir(base) { filter_entries(Dir.entries('.')) }
141
+
142
+ self.read_posts(dir)
143
+
144
+ if self.show_drafts
145
+ self.read_drafts(dir)
146
+ end
147
+
148
+ self.posts.sort!
149
+
150
+ # limit the posts if :limit_posts option is set
151
+ if limit_posts > 0
152
+ limit = self.posts.length < limit_posts ? self.posts.length : limit_posts
153
+ self.posts = self.posts[-limit, limit]
154
+ end
155
+
156
+ entries.each do |f|
157
+ f_abs = File.join(base, f)
158
+ f_rel = File.join(dir, f)
159
+ if File.directory?(f_abs)
160
+ next if self.dest.sub(/\/$/, '') == f_abs
161
+ read_directories(f_rel)
162
+ else
163
+ first3 = File.open(f_abs) { |fd| fd.read(3) }
164
+ if first3 == "---"
165
+ # file appears to have a YAML header so process it as a page
166
+ pages << Page.new(self, self.source, dir, f)
167
+ else
168
+ # otherwise treat it as a static file
169
+ static_files << StaticFile.new(self, self.source, dir, f)
170
+ end
171
+ end
172
+ end
173
+ end
174
+
175
+ # Read all the files in <source>/<dir>/_posts and create a new Post
176
+ # object with each one.
177
+ #
178
+ # dir - The String relative path of the directory to read.
179
+ #
180
+ # Returns nothing.
181
+ def read_posts(dir)
182
+ entries = get_entries(dir, '_posts')
183
+
184
+ # first pass processes, but does not yet render post content
185
+ entries.each do |f|
186
+ if Post.valid?(f)
187
+ post = Post.new(self, self.source, dir, f)
188
+
189
+ if post.published && (self.future || post.date <= self.time)
190
+ aggregate_post_info(post)
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ # Read all the files in <source>/<dir>/_drafts and create a new Post
197
+ # object with each one.
198
+ #
199
+ # dir - The String relative path of the directory to read.
200
+ #
201
+ # Returns nothing.
202
+ def read_drafts(dir)
203
+ entries = get_entries(dir, '_drafts')
204
+
205
+ # first pass processes, but does not yet render draft content
206
+ entries.each do |f|
207
+ if Draft.valid?(f)
208
+ draft = Draft.new(self, self.source, dir, f)
209
+
210
+ aggregate_post_info(draft)
211
+ end
212
+ end
213
+ end
214
+
215
+ # Run each of the Generators.
216
+ #
217
+ # Returns nothing.
218
+ def generate
219
+ self.generators.each do |generator|
220
+ generator.generate(self)
221
+ end
222
+ end
223
+
224
+ # Render the site to the destination.
225
+ #
226
+ # Returns nothing.
227
+ def render
228
+ payload = site_payload
229
+ self.posts.each do |post|
230
+ post.render(self.layouts, payload)
231
+ end
232
+
233
+ self.pages.each do |page|
234
+ page.render(self.layouts, payload)
235
+ end
236
+
237
+ self.categories.values.map { |ps| ps.sort! { |a, b| b <=> a } }
238
+ self.tags.values.map { |ps| ps.sort! { |a, b| b <=> a } }
239
+ rescue Errno::ENOENT => e
240
+ # ignore missing layout dir
241
+ end
242
+
243
+ # Remove orphaned files and empty directories in destination.
244
+ #
245
+ # Returns nothing.
246
+ def cleanup
247
+ # all files and directories in destination, including hidden ones
248
+ dest_files = Set.new
249
+ Dir.glob(File.join(self.dest, "**", "*"), File::FNM_DOTMATCH) do |file|
250
+ if self.keep_files.length > 0
251
+ dest_files << file unless file =~ /\/\.{1,2}$/ || file =~ keep_file_regex
252
+ else
253
+ dest_files << file unless file =~ /\/\.{1,2}$/
254
+ end
255
+ end
256
+
257
+ # files to be written
258
+ files = Set.new
259
+ self.posts.each do |post|
260
+ files << post.destination(self.dest)
261
+ end
262
+ self.pages.each do |page|
263
+ files << page.destination(self.dest)
264
+ end
265
+ self.static_files.each do |sf|
266
+ files << sf.destination(self.dest)
267
+ end
268
+
269
+ # adding files' parent directories
270
+ dirs = Set.new
271
+ files.each { |file| dirs << File.dirname(file) }
272
+ files.merge(dirs)
273
+
274
+ obsolete_files = dest_files - files
275
+ FileUtils.rm_rf(obsolete_files.to_a)
276
+ end
277
+
278
+ # Private: creates a regular expression from the keep_files array
279
+ #
280
+ # Examples
281
+ # ['.git','.svn'] creates the following regex: /\/(\.git|\/.svn)/
282
+ #
283
+ # Returns the regular expression
284
+ def keep_file_regex
285
+ or_list = self.keep_files.join("|")
286
+ pattern = "\/(#{or_list.gsub(".", "\.")})"
287
+ Regexp.new pattern
288
+ end
289
+
290
+ # Write static files, pages, and posts.
291
+ #
292
+ # Returns nothing.
293
+ def write
294
+ self.posts.each do |post|
295
+ post.write(self.dest)
296
+ end
297
+ self.pages.each do |page|
298
+ page.write(self.dest)
299
+ end
300
+ self.static_files.each do |sf|
301
+ sf.write(self.dest)
302
+ end
303
+ end
304
+
305
+ # Construct a Hash of Posts indexed by the specified Post attribute.
306
+ #
307
+ # post_attr - The String name of the Post attribute.
308
+ #
309
+ # Examples
310
+ #
311
+ # post_attr_hash('categories')
312
+ # # => { 'tech' => [<Post A>, <Post B>],
313
+ # # 'ruby' => [<Post B>] }
314
+ #
315
+ # Returns the Hash: { attr => posts } where
316
+ # attr - One of the values for the requested attribute.
317
+ # posts - The Array of Posts with the given attr value.
318
+ def post_attr_hash(post_attr)
319
+ # Build a hash map based on the specified post attribute ( post attr =>
320
+ # array of posts ) then sort each array in reverse order.
321
+ hash = Hash.new { |hsh, key| hsh[key] = Array.new }
322
+ self.posts.each { |p| p.send(post_attr.to_sym).each { |t| hash[t] << p } }
323
+ hash.values.map { |sortme| sortme.sort! { |a, b| b <=> a } }
324
+ hash
325
+ end
326
+
327
+ # The Hash payload containing site-wide data.
328
+ #
329
+ # Returns the Hash: { "site" => data } where data is a Hash with keys:
330
+ # "time" - The Time as specified in the configuration or the
331
+ # current time if none was specified.
332
+ # "posts" - The Array of Posts, sorted chronologically by post date
333
+ # and then title.
334
+ # "pages" - The Array of all Pages.
335
+ # "html_pages" - The Array of HTML Pages.
336
+ # "categories" - The Hash of category values and Posts.
337
+ # See Site#post_attr_hash for type info.
338
+ # "tags" - The Hash of tag values and Posts.
339
+ # See Site#post_attr_hash for type info.
340
+ def site_payload
341
+ {"site" => self.config.merge({
342
+ "time" => self.time,
343
+ "posts" => self.posts.sort { |a, b| b <=> a },
344
+ "pages" => self.pages,
345
+ "html_pages" => self.pages.reject { |page| !page.html? },
346
+ "categories" => post_attr_hash('categories'),
347
+ "tags" => post_attr_hash('tags')})}
348
+ end
349
+
350
+ # Filter out any files/directories that are hidden or backup files (start
351
+ # with "." or "#" or end with "~"), or contain site content (start with "_"),
352
+ # or are excluded in the site configuration, unless they are web server
353
+ # files such as '.htaccess'.
354
+ #
355
+ # entries - The Array of String file/directory entries to filter.
356
+ #
357
+ # Returns the Array of filtered entries.
358
+ def filter_entries(entries)
359
+ entries.reject do |e|
360
+ unless self.include.glob_include?(e)
361
+ ['.', '_', '#'].include?(e[0..0]) ||
362
+ e[-1..-1] == '~' ||
363
+ self.exclude.glob_include?(e) ||
364
+ (File.symlink?(e) && self.safe)
365
+ end
366
+ end
367
+ end
368
+
369
+ # Get the implementation class for the given Converter.
370
+ #
371
+ # klass - The Class of the Converter to fetch.
372
+ #
373
+ # Returns the Converter instance implementing the given Converter.
374
+ def getConverterImpl(klass)
375
+ matches = self.converters.select { |c| c.class == klass }
376
+ if impl = matches.first
377
+ impl
378
+ else
379
+ raise "Converter implementation not found for #{klass}"
380
+ end
381
+ end
382
+
383
+ # Create array of instances of the subclasses of the class or module
384
+ # passed in as argument.
385
+ #
386
+ # klass - class or module containing the subclasses which should be
387
+ # instantiated
388
+ #
389
+ # Returns array of instances of subclasses of parameter
390
+ def instantiate_subclasses(klass)
391
+ klass.subclasses.select do |c|
392
+ !self.safe || c.safe
393
+ end.sort.map do |c|
394
+ c.new(self.config)
395
+ end
396
+ end
397
+
398
+ # Read the entries from a particular directory for processing
399
+ #
400
+ # dir - The String relative path of the directory to read
401
+ # subfolder - The String directory to read
402
+ #
403
+ # Returns the list of entries to process
404
+ def get_entries(dir, subfolder)
405
+ base = File.join(self.source, dir, subfolder)
406
+ return [] unless File.exists?(base)
407
+ entries = Dir.chdir(base) { filter_entries(Dir['**/*']) }
408
+ entries.delete_if { |e| File.directory?(File.join(base, e)) }
409
+ end
410
+
411
+ # Aggregate post information
412
+ #
413
+ # post - The Post object to aggregate information for
414
+ #
415
+ # Returns nothing
416
+ def aggregate_post_info(post)
417
+ self.posts << post
418
+ post.categories.each { |c| self.categories[c] << post }
419
+ post.tags.each { |c| self.tags[c] << post }
420
+ end
421
+ end
422
+ end
@@ -0,0 +1,70 @@
1
+ module Moft
2
+ class StaticFile
3
+ # The cache of last modification times [path] -> mtime.
4
+ @@mtimes = Hash.new
5
+
6
+ # Initialize a new StaticFile.
7
+ #
8
+ # site - The Site.
9
+ # base - The String path to the <source>.
10
+ # dir - The String path between <source> and the file.
11
+ # name - The String filename of the file.
12
+ def initialize(site, base, dir, name)
13
+ @site = site
14
+ @base = base
15
+ @dir = dir
16
+ @name = name
17
+ end
18
+
19
+ # Returns source file path.
20
+ def path
21
+ File.join(@base, @dir, @name)
22
+ end
23
+
24
+ # Obtain destination path.
25
+ #
26
+ # dest - The String path to the destination dir.
27
+ #
28
+ # Returns destination file path.
29
+ def destination(dest)
30
+ File.join(dest, @dir, @name)
31
+ end
32
+
33
+ # Returns last modification time for this file.
34
+ def mtime
35
+ File.stat(path).mtime.to_i
36
+ end
37
+
38
+ # Is source path modified?
39
+ #
40
+ # Returns true if modified since last write.
41
+ def modified?
42
+ @@mtimes[path] != mtime
43
+ end
44
+
45
+ # Write the static file to the destination directory (if modified).
46
+ #
47
+ # dest - The String path to the destination dir.
48
+ #
49
+ # Returns false if the file was not modified since last time (no-op).
50
+ def write(dest)
51
+ dest_path = destination(dest)
52
+
53
+ return false if File.exist?(dest_path) and !modified?
54
+ @@mtimes[path] = mtime
55
+
56
+ FileUtils.mkdir_p(File.dirname(dest_path))
57
+ FileUtils.cp(path, dest_path)
58
+
59
+ true
60
+ end
61
+
62
+ # Reset the mtimes cache (for testing purposes).
63
+ #
64
+ # Returns nothing.
65
+ def self.reset_cache
66
+ @@mtimes = Hash.new
67
+ nil
68
+ end
69
+ end
70
+ end