# hx - A very small website generator. # # Copyright (c) 2009 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 'cgi' require 'rubygems' require 'ostruct' require 'set' require 'date' require 'time' require 'fileutils' require 'pathname' require 'yaml' require 'liquid' require 'redcloth' module Hx class NoSuchEntryError < RuntimeError end class EditingNotSupportedError < RuntimeError end module Source def edit_entry(path, prototype=nil) raise EditingNotSupportedError, "Editing not supported for #{path}" end def each_entry raise NotImplementedError, "#{self.class}#each_entry not implemented" end end class NullSource include Source def each_entry self end end NULL_SOURCE = NullSource.new class PathSubset include Source def initialize(source, options) @source = source @path_filter = Predicate.new(options[:only], options[:except]) end def edit_entry(path, prototype=nil) if @path_filter.accept? path @source.edit_entry(path, prototype) { |text| yield text } else raise EditingNotSupportedError, "Editing not supported for #{path}" end self end def each_entry @source.each_entry do |path, entry| yield path, entry if @path_filter.accept? path end self 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 { |s2, s, r| case when s2 ".*" when s "[^/]*" when r Regexp.quote(r) end }.join("")}$" end private :pattern_to_re end class Overlay include Source def initialize(*sources) @sources = sources end def edit_entry(path, prototype=nil) @sources.each do |source| begin source.edit_entry(path, prototype) { |text| yield text } break rescue EditingNotSupportedError end end self end def each_entry seen = Set[] @sources.each do |source| source.each_entry do |path, entry| yield path, entry unless seen.include? path seen.add path end end self end end module CircumfixPath include Source def initialize(source, options) @source = source @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 @source.edit_entry(path, prototype) { |text| yield text } self end def each_entry @source.each_entry do |path, entry| yield add_circumfix(path), entry end self end end class StripPath include CircumfixPath def edit_entry(path, prototype=nil) path = add_circumfix(path) @source.edit_entry(path, prototype) { |text| yield text } self end def each_entry @source.each_entry do |path, entry| path = strip_circumfix(path) yield path, entry if path end self end end class Cache include Source def initialize(source, options={}) @source = source @entries = nil end def edit_entry(path, prototype=nil) @source.edit_entry(path, prototype) { |text| yield text } self end def each_entry unless @entries @entries = [] @source.each_entry do |path, entry| @entries << [path, entry] end end @entries.each do |path, entry| yield path, entry.dup end self end end class Sort include Source def initialize(source, options) @source = source @key_fields = Array(options[:sort_by] || []).map { |f| f.to_s } @reverse = !!options[:reverse] end def edit_entry(path, prototype=nil) @source.edit_entry(path, prototype) { |text| yield text } self end def each_entry entries = [] @source.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 end Chain = Object.new def Chain.new(source, options) filters = options[:chain] || [] options = options.dup options.delete(:chain) # prevent inheritance for raw_filter in filters source = Hx.build_source(options, source, {}, raw_filter) end source 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(".") $:.push Hx.get_pathname(options, :lib_dir).to_s require library 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_source) case raw_source when Array # rewrite array to Hx::Chain return NULL_SOURCE if raw_source.empty? filter_defs = raw_source.dup first_filter = filter_defs[0] = filter_defs[0].dup raw_source = { 'filter' => 'Hx::Chain', 'options' => {'chain' => filter_defs} } if first_filter.has_key? 'source' # use input of first filter for chain raw_source['source'] = first_filter['source'] first_filter.delete('source') end end raw_source end def self.build_source(options, default_input, sources, raw_source) raw_source = expand_chain(raw_source) if raw_source.has_key? 'source' input_name = raw_source['source'] 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 Source attr_reader :options attr_reader :sources attr_reader :outputs class << self private :new 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 source 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? 'source' source_dependencies[name] = raw_source['source'] end end # calculate depth for each source 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_SOURCE, sources, raw_source) end outputs = [] for raw_output in raw_config.fetch('outputs', []) outputs << Hx.build_source(options, NULL_SOURCE, 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 @combined_output.each_entry do |path, entry| yield path, entry end self end end class FileBuilder def initialize(output_dir) @output_dir = output_dir end def build_file(path, entry) filename = File.join(@output_dir, path) dirname = File.dirname(filename) FileUtils.mkdir_p dirname File.open(filename, "wb") do |stream| stream.write entry['content'].to_s end end end module Backend class Hobix include Source def initialize(source, options) @entry_dir = Hx.get_pathname(options, :entry_dir) end def yaml_repr(value) YAML.parse(YAML.dump(value)) end private :yaml_repr def edit_entry(path, prototype=nil) entry_filename = @entry_dir + "#{path}.yaml" begin text = entry_filename.read previous_mtime = entry_filename.mtime rescue Errno::ENOENT raise NoSuchEntryError, path unless prototype prototype = prototype.dup prototype['content'] = (prototype['content'] || "").dup content = prototype['content'] def content.to_yaml_style ; :literal ; end native = YAML::DomainType.new('hobix.com,2004', 'entry', prototype) text = YAML.dump(native) previous_mtime = nil end text = yield text repr = YAML.parse(text) keys = {} repr.value.each_key { |key| keys[key.value] = key } %w(created updated).each { |name| keys[name] ||= yaml_repr(name) } update_time = Time.now update_time_repr = yaml_repr(update_time) previous_mtime ||= update_time previous_mtime_repr = yaml_repr(previous_mtime) repr.add(keys['created'], previous_mtime_repr) unless repr['created'] repr.add(keys['updated'], update_time_repr) entry_filename.parent.mkpath() entry_filename.open('w') { |stream| stream << repr.emit } self end def each_entry Pathname.glob(@entry_dir + '**/*.yaml') do |entry_filename| path = entry_filename.relative_path_from(@entry_dir).to_s path.sub!(/\.yaml$/, '') entry = entry_filename.open('r') do |stream| YAML.load(stream).value end entry['updated'] ||= entry_filename.mtime entry['created'] ||= entry['updated'] yield path, entry end self end end end module Listing class RecursiveIndex include Source def self.new(source, options) listing = super(source, options) if options.has_key? :limit listing = Limit.new(listing, :limit => options[:limit]) end if options.has_key? :page_size listing = Paginate.new(listing, :page_size => options[:page_size]) end listing end def initialize(source, options) @source = source end def each_entry indexes = Hash.new { |h,k| h[k] = {'items' => []} } @source.each_entry do |path, entry| components = path.split("/") until components.empty? components.pop index_path = (components + ["index"]).join("/") index = indexes[index_path] index['items'] << {'path' => path, 'entry' => entry} if entry['modified'] and (not index['modified'] or entry['modified'] > index['modified']) index['modified'] = entry['modified'] end end end indexes.each do |path, entry| yield path, entry end self end end class Paginate include Source def initialize(source, options) @source = source @page_size = options[:page_size] end def each_entry @source.each_entry do |index_path, index_entry| items = index_entry['items'] || [] if items.empty? index_entry = index_entry.dup index_entry['pages'] = [index_entry] index_entry['page_index'] = 0 yield index_path, index_entry else pages = [] n_pages = (items.size + @page_size - 1) / @page_size for num in 0...n_pages page_items = items[@page_size * num, @page_size] entry = index_entry.dup entry['items'] = page_items entry['prev_page'] = "#{num}" entry['next_page'] = "#{num+2}" entry['pages'] = pages entry['page_index'] = num pages << {'path' => "#{index_path}/#{num+1}", 'entry' => entry} end pages[0]['path'] = index_path pages[0]['entry'].delete('prev_page') if pages.size > 1 index_name = index_path.split('/').last pages[0]['entry']['next_page'] = "#{index_name}/2" pages[1]['entry']['prev_page'] = "../#{index_name}" end pages[-1]['entry'].delete('next_page') pages.each do |page| yield page['path'], page['entry'] end end end self end end class Limit include Source def initialize(source, options) @source = source @limit = options[:limit] end def each_entry @source.each_entry do |path, entry| if entry['items'] trimmed_entry = entry.dup trimmed_entry['items'] = entry['items'][0...@limit] else trimmed_entry = entry end yield path, trimmed_entry end self end end end module Output class LiquidTemplate include Source module TextFilters def textilize(input) RedCloth.new(input).to_html end def escape_url(input) CGI.escape(input) end def escape_xml(input) CGI.escapeHTML(input) end def path_to_url(input, base_url) "#{base_url}#{input}" end def handleize(input) "id_#{input.to_s.gsub(/[^A-Za-z0-9]/, '_')}" end def xsd_datetime(input) input = Time.parse(input) unless Time === input input.xmlschema end end def initialize(source, options) @source = source @options = {} for key, value in options @options[key.to_s] = value end template_dir = Hx.get_pathname(options, :template_dir) # global, so all LiquidTemplate instances kind of have to agree on the # same template directory for things to work right Liquid::Template.file_system = Liquid::LocalFileSystem.new(template_dir) Liquid::Template.register_filter(TextFilters) template_file = template_dir + options[:template] @template = template_file.open('r') { |s| Liquid::Template.parse(s.read) } @extension = options[:extension] end def each_entry @source.each_entry do |path, entry| unless @extension.nil? output_path = "#{path}.#{@extension}" else output_path = path end output_entry = entry.dup output_entry['content'] = @template.render( 'now' => Time.now, 'options' => @options, 'path' => path, 'entry' => entry ) yield output_path, output_entry end self end end end end