# hx - A very small website generator. # # Copyright (c) 2009-2010 MenTaLguY <mental@rydia.net> # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. require 'rubygems' require 'thread' require 'set' require 'pathname' require 'tempfile' require 'yaml' module Hx VERSION = (Pathname.new(__FILE__).parent.parent + 'VERSION').read.strip class NoSuchEntryError < RuntimeError end class EditingNotSupportedError < RuntimeError end module Filter def edit_entry(path, prototype=nil) raise EditingNotSupportedError, "Editing not supported for #{path}" end def each_entry_path each_entry { |path, entry| yield path } end def each_entry each_entry_path do |path| begin entry = get_entry(path) rescue NoSuchEntryError next # entries may come and go during the enumeration end yield path, entry end end def get_entry(path) each_entry do |entry_path, entry| return entry if entry_path == path end raise NoSuchEntryError, path end end class NullInput include Filter def each_entry self end end NULL_INPUT = NullInput.new class PathSubset include Filter def initialize(input, options) @input = input @path_filter = Predicate.new(options[:only], options[:except]) end def edit_entry(path, prototype=nil) if @path_filter.accept? path @input.edit_entry(path, prototype) { |text| yield text } else raise EditingNotSupportedError, "Editing not supported for #{path}" end self end def each_entry_path @input.each_entry_path do |path| yield path if @path_filter.accept? path end self end def get_entry(path) raise NoSuchEntryError, path unless @path_filter.accept? path @input.get_entry(path) end end class PathSubset::Predicate def initialize(accept, reject) @accept_re = patterns_to_re(accept) @reject_re = patterns_to_re(reject) end def accept?(path) (not @accept_re or path =~ @accept_re) and (not @reject_re or path !~ @reject_re) end def patterns_to_re(patterns) return nil if patterns.nil? or patterns.empty? patterns = Array(patterns) Regexp.new("(?:#{patterns.map { |p| pattern_to_re(p) }.join("|")})") end private :patterns_to_re def pattern_to_re(pattern) "^#{pattern.scan(/(\*\*?|[^*]+)/).map { |s,| case s when "**"; ".*" when "*"; "[^/]*" else Regexp.quote(s) end }}$" end private :pattern_to_re end class Overlay include Filter def initialize(*inputs) @inputs = inputs end def each_entry_path seen = Set[] @inputs.each do |input| input.each_entry_path do |path| yield path unless seen.include? path seen.add path end end self end def get_entry(path) @inputs.each do |input| begin return input.get_entry(path) rescue NoSuchEntryError end end raise NoSuchEntryError, path end end module CircumfixPath include Filter def initialize(input, options) @input = input @prefix = options[:prefix] @suffix = options[:suffix] prefix = Regexp.quote(@prefix.to_s) suffix = Regexp.quote(@suffix.to_s) @regexp = Regexp.new("^#{prefix}(.*)#{suffix}$") end private def add_circumfix(path) "#{@prefix}#{path}#{@suffix}" end def strip_circumfix(path) path =~ @regexp ; $1 end end class AddPath include CircumfixPath def edit_entry(path, prototype=nil) path = strip_circumfix(path) raise EditingNotSupportedError, "Editing not supported for #{path}" unless path @input.edit_entry(path, prototype) { |text| yield text } self end def each_entry_path @input.each_entry_path { |path| yield add_circumfix(path) } self end def get_entry(path) path = strip_circumfix(path) raise NoSuchEntryError, path unless path @input.get_entry(path) end end class StripPath include CircumfixPath def edit_entry(path, prototype=nil) path = add_circumfix(path) @input.edit_entry(path, prototype) { |text| yield text } self end def each_entry_path @input.each_entry_path do |path| path = strip_circumfix(path) yield path if path end self end def get_entry(path) @input.get_entry(add_circumfix(path)) end end class Cache include Filter def initialize(input, options={}) @input = input @lock = Mutex.new @entries = nil @entries_by_path = {} end def edit_entry(path, prototype=nil) @input.edit_entry(path, prototype) { |text| yield text } self end def each_entry entries = nil @lock.synchronize do if @entries entries = @entries else entries = [] @input.each_entry do |path, entry| @entries_by_path[path] = entry entries << [path, entry] end @entries = entries end end entries.each do |path, entry| yield path, entry.dup end self end def get_entry(path) entry = nil @lock.synchronize do if @entries_by_path.has_key? path entry = @entries_by_path[path] else entry = @input.get_entry(path) @entries_by_path[path] = entry end end return entry.dup end end class Sort include Filter def initialize(input, options) @input = input @key_fields = Array(options[:sort_by] || []).map { |f| f.to_s } @reverse = !!options[:reverse] end def edit_entry(path, prototype=nil) @input.edit_entry(path, prototype) { |text| yield text } self end def each_entry entries = [] @input.each_entry do |path, entry| entries << [path, entry] end unless @key_fields.empty? entries = entries.sort_by do |path, entry| @key_fields.map { |f| entry[f] } end end entries.reverse! if @reverse entries.each do |path, entry| yield path, entry end self end def get_entry(path) @input.get_entry(path) end end Chain = Object.new def Chain.new(input, options) filters = options[:chain] || [] options = options.dup options.delete(:chain) # prevent inheritance for raw_filter in filters input = Hx.build_source(options, input, {}, raw_filter) end input end def self.make_default_title(options, path) name = path.split('/').last words = name.split(/[_\s-]/) words.map { |w| w.capitalize }.join(' ') end def self.get_pathname(options, key) dir = Pathname.new(options[key] || ".") if dir.relative? base_dir = Pathname.new(options[:base_dir]) (base_dir + dir).cleanpath(true) else dir end end def self.get_default_author(options) options.fetch(:default_author, "nobody") end def self.local_require(options, library) saved_require_path = $:.dup begin $:.delete(".") lib_dir = Hx.get_pathname(options, :lib_dir) if lib_dir.relative? $:.push "./#{lib_dir}" else $:.push lib_dir.to_s end require library rescue LoadError raise ensure $:[0..-1] = saved_require_path end end def self.resolve_constant(qualified_name, root=Object) begin qualified_name.split('::').inject(root) { |c, n| c.const_get(n) } rescue NameError raise NameError, "Unable to resolve #{qualified_name}" end end def self.expand_chain(raw_input) case raw_input when Array # rewrite array to Hx::Chain return NULL_INPUT if raw_input.empty? filter_defs = raw_input.dup first_filter = filter_defs[0] = filter_defs[0].dup raw_input = { 'filter' => 'Hx::Chain', 'options' => {'chain' => filter_defs} } if first_filter.has_key? 'input' # use input of first filter for chain raw_input['input'] = first_filter['input'] first_filter.delete('input') end end raw_input end def self.build_source(options, default_input, sources, raw_source) raw_source = expand_chain(raw_source) if raw_source.has_key? 'input' input_name = raw_source['input'] begin source = sources.fetch(input_name) rescue IndexError raise NameError, "No source named #{input_name} in scope" end else source = default_input end if raw_source.has_key? 'filter' if raw_source.has_key? 'options' filter_options = options.dup for key, value in raw_source['options'] filter_options[key.intern] = value end else filter_options = options end filter = raw_source['filter'] begin factory = Hx.resolve_constant(filter) rescue NameError library = filter.gsub(/::/, '/').downcase Hx.local_require(options, library) factory = Hx.resolve_constant(filter) end source = factory.new(source, filter_options) end if raw_source.has_key? 'only' or raw_source.has_key? 'except' source = PathSubset.new(source, :only => raw_source['only'], :except => raw_source['except']) end if raw_source.has_key? 'strip_prefix' or raw_source.has_key? 'strip_suffix' source = StripPath.new(source, :prefix => raw_source['strip_prefix'], :suffix => raw_source['strip_suffix']) end if raw_source.has_key? 'add_prefix' or raw_source.has_key? 'add_suffix' source = AddPath.new(source, :prefix => raw_source['add_prefix'], :suffix => raw_source['add_suffix']) end if raw_source.has_key? 'sort_by' or raw_source.has_key? 'reverse' source = Sort.new(source, :sort_by => raw_source['sort_by'], :reverse => raw_source['reverse']) end source = Cache.new(source) if raw_source['cache'] source end class Site include Filter attr_reader :options attr_reader :sources attr_reader :outputs class << self private :new def load_file(config_file) File.open(config_file, 'r') do |stream| load(stream, config_file) end end def load(io, config_path) raw_config = YAML.load(io) options = {} options[:base_dir] = File.dirname(config_path) for key, value in raw_config.fetch('options', {}) options[key.intern] = value end if raw_config.has_key? 'require' for library in raw_config['require'] Hx.local_require(options, library) end end raw_sources_by_name = raw_config.fetch('sources', {}) source_names = raw_sources_by_name.keys # build input dependency graph source_dependencies = {} for name, raw_source in raw_sources_by_name raw_source = Hx.expand_chain(raw_source) if raw_source.has_key? 'input' source_dependencies[name] = raw_source['input'] end end # calculate depth for each input in the graph source_depths = Hash.new(0) for name in source_names seen = Set[] # for cycle detection while source_dependencies.has_key? name if seen.include? name raise RuntimeError, "cycle in source graph at #{name}" end seen.add name depth = source_depths[name] + 1 name = source_dependencies[name] source_depths[name] = depth if depth > source_depths[name] end end # depth-first topological sort depth_first_names = source_names.sort_by { |n| -source_depths[n] } sources = {} for name in depth_first_names raw_source = raw_sources_by_name[name] sources[name] = Hx.build_source(options, NULL_INPUT, sources, raw_source) end outputs = [] for raw_output in raw_config.fetch('outputs', []) outputs << Hx.build_source(options, NULL_INPUT, sources, raw_output) end new(options, sources, outputs) end end def initialize(options, sources, outputs) @options = options @sources = sources @outputs = outputs @combined_output = Overlay.new(*@outputs) end def edit_entry(path, prototype=nil) @combined_output.edit_entry(path, prototype) { |text| yield text } self end def each_entry_path @combined_output.each_entry_path { |path| yield path } self end def get_entry(path) @combined_output.get_entry(path) end end def self.refresh_file(pathname, content, update_time) begin return false if update_time and update_time < pathname.mtime rescue Errno::ENOENT end write_file(pathname, content) true end def self.write_file(pathname, content) parent = pathname.parent parent.mkpath() tempfile = Tempfile.new('.hx-out', parent.to_s) begin File.open(tempfile.path, "wb") { |stream| stream << content.to_s } File.chmod(0666 & ~File.umask, tempfile.path) File.rename(tempfile.path, pathname.to_s) ensure tempfile.unlink end nil end class LazyContent def initialize(&block) raise ArgumentError, "No block given" unless block @lock = Mutex.new @content = nil @block = block end def to_s @lock.synchronize do if @block @content = @block.call @block = nil end end @content end def to_yaml(*args) to_s.to_yaml(*args) end def to_json(*args) to_s.to_json(*args) end def to_liquid(*args) to_s.to_liquid(*args) end end end