# 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 'thread' require 'set' require 'pathname' require 'tempfile' require 'yaml' require 'hx/path' require 'set' module Hx VERSION = (Pathname.new(__FILE__).parent.parent + 'VERSION').read.strip class NoSuchEntryError < RuntimeError end class EditingNotSupportedError < RuntimeError end # minimal complete definition: each_entry_path + get_entry, or each_entry module Filter def edit_entry(path, prototype=nil) raise EditingNotSupportedError, "Editing not supported for #{path}" end def each_entry_path(selector) each_entry(selector) { |path, entry| yield path } end def each_entry(selector) each_entry_path(selector) 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(Path.literal(path)) do |entry_path, entry| return entry end raise NoSuchEntryError, path end end class NullInput include Filter def each_entry(selector) self end end NULL_INPUT = NullInput.new class PathSubset include Filter def patterns_to_selector(patterns) patterns.map { |p| Path::parse_pattern(p) }.inject { |a, b| a | b } end private :patterns_to_selector def initialize(input, options) @input = input only = patterns_to_selector(Array(options[:only] || [])) except = patterns_to_selector(Array(options[:except] || [])) except = ~except if except if only and except @selector = only & except else @selector = only || except || Path::ALL end end def edit_entry(path, prototype=nil) if @selector.accept_path? path @input.edit_entry(path, prototype) { |text| yield text } else raise EditingNotSupportedError, "Editing not supported for #{path}" end self end def each_entry_path(selector, &block) @input.each_entry_path(@selector & selector, &block) self end def each_entry(selector, &block) @input.each_entry(@selector & selector, &block) self end def get_entry(path) raise NoSuchEntryError, path unless @selector.accept_path? path @input.get_entry(path) end end class Overlay include Filter def initialize(*inputs) @inputs = inputs end def each_entry_path(selector) seen = Set[] @inputs.each do |input| input.each_entry_path(selector) 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] @regexp = Path.make_circumfix_re(@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(selector) selector = selector.assume_circumfix(@prefix, @suffix) @input.each_entry_path(selector) do |path| yield add_circumfix(path) end 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(selector) selector = selector.elide_circumfix(@prefix, @suffix) @input.each_entry_path(selector) do |path| yield strip_circumfix(path) end self end def get_entry(path) @input.get_entry(add_circumfix(path)) end end def self.cache_scope saved_records = Thread.current[:hx_cache_records] Thread.current[:hx_cache_records] = {} begin yield ensure Thread.current[:hx_cache_records] = saved_records end end class Cache include Filter class Record def initialize @paths = nil @entries = {} end def clear_path(path) @paths = nil @entries.delete path end def get_entry_paths @paths || (@paths = yield) end def get_entry(path) begin entry = @entries.fetch(path) rescue IndexError begin entry = yield rescue NoSuchEntryError entry = nil end @entries[path] = entry end raise NoSuchEntryError, path unless entry entry end end attr_reader :input def initialize(input, options={}) input = input.input while Cache === input @input = input end def get_cache_record (Thread.current[:hx_cache_records] ||= {})[@input] ||= Record.new end def edit_entry(path, prototype=nil) @input.edit_entry(path, prototype) { |text| yield text } get_cache_record.clear_path(path) self end def each_entry_path(selector) get_cache_record.get_entry_paths do paths = [] @input.each_entry_path(Path::ALL) { |path| paths << path } paths end.each do |path| yield path if selector.accept_path? path end self end def get_entry(path) get_cache_record.get_entry(path) { @input.get_entry(path) } end end class Sort include Filter def initialize(input, options) @input = Cache.new(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(selector) entries = [] @input.each_entry(selector) 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 raw_filter = Hx.expand_chain(raw_filter) 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.get_input_names(raw_source) if raw_source.has_key? 'input' return Array(raw_source['input']) else [] end end def self.build_source(options, default_input, sources, raw_source) input_names = get_input_names(raw_source) input_sources = input_names.map do |input_name| begin sources.fetch(input_name) rescue IndexError raise NameError, "No source named #{input_name} in scope" end end case input_sources.length when 0 source = default_input when 1 source = input_sources.first else source = Overlay.new(*input_sources) 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 end class Site include Filter attr_reader :options attr_reader :sources class << self private :new def load_file(config_file, option_overrides={}) File.open(config_file, 'r') do |stream| load(stream, config_file, option_overrides) end end def load(io, config_file, option_overrides={}) raw_config = YAML.load(io) options = {} options[:base_dir] = File.dirname(config_file) for key, value in raw_config.fetch('options', {}) options[key.intern] = value end options[:config_file] = config_file options.update(option_overrides) 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', {}) raw_outputs = raw_config.fetch('output', []) for name, raw_source in raw_sources_by_name raw_sources_by_name[name] = Hx.expand_chain(raw_source) end raw_outputs = raw_outputs.map! do |raw_output| Hx.expand_chain(raw_output) end # build input dependency graph source_dependencies = Hash.new { |h,k| h[k] = Set.new } source_count_by_dependency = Hash.new(0) for name, raw_source in raw_sources_by_name for input_name in Hx.get_input_names(raw_source) source_dependencies[name].add input_name source_count_by_dependency[input_name] += 1 end end for raw_output in raw_outputs for input_name in Hx.get_input_names(raw_source) source_count_by_dependency[input_name] += 1 end end source_names = raw_sources_by_name.keys # calculate depth for each input in the graph source_depths = Hash.new(0) to_process = Set.new(source_names) until to_process.empty? need_updating = Set.new for name in to_process depth = source_depths[name] + 1 if depth >= source_names.length raise "cycle in source graph involving #{name}" end for input_name in source_dependencies[name] if depth > source_depths[input_name] source_depths[input_name] = depth need_updating.add input_name end end end to_process = need_updating 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] source = Hx.build_source(options, NULL_INPUT, sources, raw_source) if source_count_by_dependency[name] > 1 source = Cache.new(source, options) end sources[name] = source end outputs = [] for raw_output in raw_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 @output = Overlay.new(*outputs) end def edit_entry(path, prototype=nil) @output.edit_entry(path, prototype) { |text| yield text } self end def each_entry_path(selector) @output.each_entry_path(selector) { |path| yield path } self end def get_entry(path) @output.get_entry(path) end end def self.refresh_file(pathname, content, update_time, executable=false) begin return false if update_time and update_time < pathname.mtime rescue Errno::ENOENT end write_file(pathname, content, executable) true end def self.write_file(pathname, content, executable=false) parent = pathname.parent parent.mkpath() tempfile = Tempfile.new('.hx-out', parent.to_s) begin File.open(tempfile.path, "wb") { |stream| stream << content.to_s } base_mode = 0666 base_mode |= 0111 if executable File.chmod(base_mode & ~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