diff --git a/features/collections.feature b/features/collections.feature new file mode 100644 index 000000000..451c84d87 --- /dev/null +++ b/features/collections.feature @@ -0,0 +1,38 @@ +Feature: Collections + As a hacker who likes to structure content + I want to be able to create collections of similar information + And render them + + Scenario: Unrendered collection + Given I have an "index.html" page that contains "Collections: {{ site.methods }}" + And I have fixture collections + And I have a configuration file with "collections" set to "['methods']" + When I run jekyll + Then the _site directory should exist + And I should see "Collections: Use `{{ page.title }}` to build a full configuration for use w/Jekyll.\n\nWhatever: {{ page.whatever }}\n`{{ page.title }}` is used to make sure your path is in your source.\nRun your generators! {{ page.layout }}\nCreate dat site.\nRun your generators! {{ page.layout }}" in "_site/index.html" + + Scenario: Rendered collection + Given I have an "index.html" page that contains "Collections: {{ site.collections }}" + And I have fixture collections + And I have a configuration file with: + | key | value | + | collections | ['methods'] | + | render | ['methods'] | + When I run jekyll + Then the _site directory should exist + And I should see "Collections: methods" in "_site/index.html" + And I should see "

Whatever: foo.bar

" in "_site/methods/configuration.html" + + Scenario: Rendered document in a layout + Given I have an "index.html" page that contains "Collections: {{ site.collections }}" + And I have a default layout that contains "
Tom Preston-Werner
{{content}}" + And I have fixture collections + And I have a configuration file with: + | key | value | + | collections | ['methods'] | + | render | ['methods'] | + When I run jekyll + Then the _site directory should exist + And I should see "Collections: methods" in "_site/index.html" + And I should see "

Run your generators! default

" in "_site/methods/site/generate.html" + And I should see "
Tom Preston-Werner
" in "_site/methods/site/generate.html" \ No newline at end of file diff --git a/features/step_definitions/jekyll_steps.rb b/features/step_definitions/jekyll_steps.rb index 93de5c108..45210630e 100644 --- a/features/step_definitions/jekyll_steps.rb +++ b/features/step_definitions/jekyll_steps.rb @@ -16,15 +16,14 @@ def file_content_from_hash(input_hash) EOF end - Before do FileUtils.mkdir_p(TEST_DIR) unless File.exist?(TEST_DIR) Dir.chdir(TEST_DIR) end After do - FileUtils.rm_rf(TEST_DIR) if File.exist?(TEST_DIR) - FileUtils.rm(JEKYLL_COMMAND_OUTPUT_FILE) + FileUtils.rm_rf(TEST_DIR) if File.exists?(TEST_DIR) + FileUtils.rm(JEKYLL_COMMAND_OUTPUT_FILE) if File.exists?(JEKYLL_COMMAND_OUTPUT_FILE) end World(Test::Unit::Assertions) @@ -130,6 +129,16 @@ Given /^I have a configuration file with "([^\"]*)" set to:$/ do |key, table| end end +Given /^I have fixture collections$/ do + FileUtils.cp_r File.join(JEKYLL_SOURCE_DIR, "test", "source", "_methods"), source_dir +end + +################## +# +# Changing stuff +# +################## + When /^I run jekyll(?: with "(.+)")?$/ do |opt| run_jekyll_build(opt) end diff --git a/features/support/env.rb b/features/support/env.rb index ad3bb38a8..5f6752aa2 100644 --- a/features/support/env.rb +++ b/features/support/env.rb @@ -6,10 +6,15 @@ require 'rr' require 'test/unit' require 'time' +JEKYLL_SOURCE_DIR = File.dirname(File.dirname(File.dirname(__FILE__))) TEST_DIR = File.expand_path(File.join('..', '..', 'tmp', 'jekyll'), File.dirname(__FILE__)) JEKYLL_PATH = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'jekyll') JEKYLL_COMMAND_OUTPUT_FILE = File.join(File.dirname(TEST_DIR), 'jekyll_output.txt') +def source_dir(*files) + File.join(TEST_DIR, *files) +end + def jekyll_output_file JEKYLL_COMMAND_OUTPUT_FILE end @@ -19,7 +24,7 @@ def jekyll_run_output end def run_jekyll(args, output_file) - command = "#{JEKYLL_PATH} #{args} > #{jekyll_output_file} 2>&1" + command = "#{JEKYLL_PATH} #{args} --trace > #{jekyll_output_file} 2>&1" system command end diff --git a/lib/jekyll.rb b/lib/jekyll.rb index e2dd369c4..d7a2c2eab 100644 --- a/lib/jekyll.rb +++ b/lib/jekyll.rb @@ -34,6 +34,8 @@ require 'jekyll/utils' require 'jekyll/stevenson' require 'jekyll/deprecator' require 'jekyll/configuration' +require 'jekyll/document' +require 'jekyll/collection' require 'jekyll/plugin_manager' require 'jekyll/site' require 'jekyll/convertible' @@ -51,6 +53,7 @@ require 'jekyll/cleaner' require 'jekyll/entry_filter' require 'jekyll/layout_reader' require 'jekyll/publisher' +require 'jekyll/renderer' # extensions require 'jekyll/plugin' diff --git a/lib/jekyll/cleaner.rb b/lib/jekyll/cleaner.rb index e3a89b4be..583fc8446 100644 --- a/lib/jekyll/cleaner.rb +++ b/lib/jekyll/cleaner.rb @@ -4,6 +4,8 @@ module Jekyll class Site # Handles the cleanup of a site's destination before it is built. class Cleaner + attr_reader :site + def initialize(site) @site = site end @@ -27,7 +29,7 @@ module Jekyll # Returns a Set with the file paths def existing_files files = Set.new - Dir.glob(File.join(@site.dest, "**", "*"), File::FNM_DOTMATCH) do |file| + Dir.glob(File.join(site.dest, "**", "*"), File::FNM_DOTMATCH) do |file| files << file unless file =~ /\/\.{1,2}$/ || file =~ keep_file_regex end files @@ -38,7 +40,7 @@ module Jekyll # Returns a Set with the file paths def new_files files = Set.new - @site.each_site_file { |item| files << item.destination(@site.dest) } + site.each_site_file { |item| files << item.destination(site.dest) } files end @@ -64,7 +66,7 @@ module Jekyll # # Returns the regular expression def keep_file_regex - or_list = @site.keep_files.join("|") + or_list = site.keep_files.join("|") pattern = "\/(#{or_list.gsub(".", "\.")})" Regexp.new pattern end diff --git a/lib/jekyll/collection.rb b/lib/jekyll/collection.rb new file mode 100644 index 000000000..ad0e5fa54 --- /dev/null +++ b/lib/jekyll/collection.rb @@ -0,0 +1,121 @@ +module Jekyll + class Collection + attr_reader :site, :label + + # Create a new Collection. + # + # site - the site to which this collection belongs. + # label - the name of the collection + # + # Returns nothing. + def initialize(site, label) + @site = site + @label = sanitize_label(label) + end + + # Fetch the Documents in this collection. + # Defaults to an empty array if no documents have been read in. + # + # Returns an array of Jekyll::Document objects. + def docs + @docs ||= [] + end + + # Read the allowed documents into the collection's array of docs. + # + # Returns the sorted array of docs. + def read + filtered_entries.each do |file_path| + doc = Jekyll::Document.new(Jekyll.sanitized_path(directory, file_path), { site: site, collection: self }) + doc.read + docs << doc + end + docs.sort! + end + + # All the entries in this collection. + # + # Returns an Array of file paths to the documents in this collection + # relative to the collection's directory + def entries + return Array.new unless exists? + Dir.glob(File.join(directory, "**", "*.*")).map do |entry| + entry[File.join(directory, "")] = ''; entry + end + end + + # Filtered version of the entries in this collection. + # See `Jekyll::EntryFilter#filter` for more information. + # + # Returns a list of filtered entry paths. + def filtered_entries + return Array.new unless exists? + Dir.chdir(directory) do + entry_filter.filter(entries) + end + end + + # The directory for this Collection, relative to the site source. + # + # Returns a String containing the directory name where the collection + # is stored on the filesystem. + def relative_directory + "_#{label}" + end + + # The full path to the directory containing the + # + # Returns a String containing th directory name where the collection + # is stored on the filesystem. + def directory + Jekyll.sanitized_path(site.source, relative_directory) + end + + # Checks whether the directory "exists" for this collection. + # The directory must exist on the filesystem and must not be a symlink + # if in safe mode. + # + # Returns false if the directory doesn't exist or if it's a symlink + # and we're in safe mode. + def exists? + File.directory?(directory) && !(File.symlink?(directory) && site.safe) + end + + # The entry filter for this collection. + # Creates an instance of Jekyll::EntryFilter. + # + # Returns the instance of Jekyll::EntryFilter for this collection. + def entry_filter + @entry_filter ||= Jekyll::EntryFilter.new(site, relative_directory) + end + + # An inspect string. + # + # Returns the inspect string + def inspect + "#" + end + + # Produce a sanitized label name + # Label names may not contain anything but alphanumeric characters, + # underscores, and hyphens. + # + # label - the possibly-unsafe label + # + # Returns a sanitized version of the label. + def sanitize_label(label) + label.gsub(/[^a-z0-9_\-]/i, '') + end + + # Produce a representation of this Collection for use in Liquid. + # Exposes two attributes: + # - label + # - docs + # + # Returns a representation of this collection for use in Liquid. + def to_liquid + docs + end + + end +end diff --git a/lib/jekyll/configuration.rb b/lib/jekyll/configuration.rb index 93dc51930..ffdb0a229 100644 --- a/lib/jekyll/configuration.rb +++ b/lib/jekyll/configuration.rb @@ -13,6 +13,7 @@ module Jekyll 'data_source' => '_data', 'keep_files' => ['.git','.svn'], 'gems' => [], + 'collections' => nil, 'timezone' => nil, # use the local timezone diff --git a/lib/jekyll/document.rb b/lib/jekyll/document.rb new file mode 100644 index 000000000..ed2005117 --- /dev/null +++ b/lib/jekyll/document.rb @@ -0,0 +1,228 @@ +module Jekyll + class Document + include Comparable + + attr_reader :path, :site + attr_accessor :content, :collection, :output + + # Create a new Document. + # + # site - the Jekyll::Site instance to which this Document belongs + # path - the path to the file + # + # Returns nothing. + def initialize(path, relations) + @site = relations[:site] + @path = path + @collection = relations[:collection] + end + + # Fetch the Document's data. + # + # Returns a Hash containing the data. An empty hash is returned if + # no data was read. + def data + @data ||= Hash.new + end + + # The path to the document, relative to the site source. + # + # Returns a String path which represents the relative path + # from the site source to this document + def relative_path + Pathname.new(path).relative_path_from(Pathname.new(site.source)).to_s + end + + # The base filename of the document. + # + # suffix - (optional) the suffix to be removed from the end of the filename + # + # Returns the base filename of the document. + def basename(suffix = "") + File.basename(path, suffix) + end + + # The extension name of the document. + # + # Returns the extension name of the document. + def extname + File.extname(path) + end + + # Produces a "cleaned" relative path. + # The "cleaned" relative path is the relative path without the extname + # and with the collection's directory removed as well. + # This method is useful when building the URL of the document. + # + # Examples: + # When relative_path is "_methods/site/generate.md": + # cleaned_relative_path + # # => "/site/generate" + # + # Returns the cleaned relative path of the document. + def cleaned_relative_path + relative_path[0 .. -extname.length - 1].sub(collection.relative_directory, "") + end + + # Determine whether the document is a YAML file. + # + # Returns true if the extname is either .yml or .yaml, false otherwise. + def yaml_file? + %w[.yaml .yml].include?(extname) + end + + # Determine whether the document is an asset file. + # Asset files include CoffeeScript files and Sass/SCSS files. + # + # Returns true if the extname belongs to the set of extensions + # that asset files use. + def asset_file? + %w[.sass .scss .coffee].include?(extname) + end + + # Determine whether the file should be rendered with Liquid. + # + # Returns false if the document is either an asset file or a yaml file, + # true otherwise. + def render_with_liquid? + !(asset_file? || yaml_file?) + end + + # The URL template where the document would be accessible. + # + # Returns the URL template for the document. + def url_template + "/:collection/:path:output_ext" + end + + # Construct a Hash of key-value pairs which contain a mapping between + # a key in the URL template and the corresponding value for this document. + # + # Returns the Hash of key-value pairs for replacement in the URL. + def url_placeholders + { + collection: collection.label, + path: cleaned_relative_path, + output_ext: Jekyll::Renderer.new(site, self).output_ext + } + end + + # The permalink for this Document. + # Permalink is set via the data Hash. + # + # Returns the permalink or nil if no permalink was set in the data. + def permalink + data && data['permalink'] + end + + # The computed URL for the document. See `Jekyll::URL#to_s` for more details. + # + # Returns the computed URL for the document. + def url + @url ||= URL.new({ + template: url_template, + placeholders: url_placeholders, + permalink: permalink + }).to_s + end + + # The full path to the output file. + # + # base_directory - the base path of the output directory + # + # Returns the full path to the output file of this document. + def destination(base_directory) + path = Jekyll.sanitized_path(base_directory, url) + path = File.join(path, "index.html") if url =~ /\/$/ + path + end + + # Write the generated Document file to the destination directory. + # + # dest - The String path to the destination dir. + # + # Returns nothing. + def write(dest) + path = destination(dest) + FileUtils.mkdir_p(File.dirname(path)) + File.open(path, 'wb') do |f| + f.write(output) + end + end + + # Returns merged option hash for File.read of self.site (if exists) + # and a given param + # + # opts - override options + # + # Return the file read options hash. + def merged_file_read_opts(opts) + site ? site.file_read_opts.merge(opts) : opts + end + + # Whether the file is published or not, as indicated in YAML front-matter + # + # Returns true if the 'published' key is specified in the YAML front-matter and not `false`. + def published? + !(data.has_key?('published') && data['published'] == false) + end + + # Read in the file and assign the content and data based on the file contents. + # + # Returns nothing. + def read(opts = {}) + if yaml_file? + @data = SafeYAML.load_file(path) + else + begin + @content = File.read(path, merged_file_read_opts(opts)) + if content =~ /\A(---\s*\n.*?\n?)^(---\s*$\n?)/m + @content = $POSTMATCH + @data = SafeYAML.load($1) + end + rescue SyntaxError => e + puts "YAML Exception reading #{path}: #{e.message}" + rescue Exception => e + puts "Error reading file #{path}: #{e.message}" + end + end + end + + # Create a Liquid-understandable version of this Document. + # + # Returns a Hash representing this Document's data. + def to_liquid + Utils.deep_merge_hashes data, { + "content" => content, + "path" => path, + "relative_path" => relative_path, + "url" => url + } + end + + # The inspect string for this document. + # Includes the relative path and the collection label. + # + # Returns the inspect string for this document. + def inspect + "#" + end + + # The string representation for this document. + # + # Returns the content of the document + def to_s + output || content + end + + # Compare this document against another document. + # Comparison is a comparison between the 2 paths of the documents. + # + # Returns -1, 0, +1 or nil depending on whether this doc's path is less than, + # equal or greater than the other doc's path. See String#<=> for more details. + def <=>(anotherDocument) + path <=> anotherDocument.path + end + + end +end diff --git a/lib/jekyll/entry_filter.rb b/lib/jekyll/entry_filter.rb index 11db9e11c..0b00218b8 100644 --- a/lib/jekyll/entry_filter.rb +++ b/lib/jekyll/entry_filter.rb @@ -1,5 +1,7 @@ module Jekyll class EntryFilter + SPECIAL_LEADING_CHARACTERS = ['.', '_', '#'].freeze + attr_reader :site def initialize(site, base_directory = nil) @@ -35,7 +37,8 @@ module Jekyll end def special?(entry) - ['.', '_', '#'].include?(entry[0..0]) + SPECIAL_LEADING_CHARACTERS.include?(entry[0..0]) || + SPECIAL_LEADING_CHARACTERS.include?(File.basename(entry)[0..0]) end def backup?(entry) diff --git a/lib/jekyll/renderer.rb b/lib/jekyll/renderer.rb new file mode 100644 index 000000000..e3d233df3 --- /dev/null +++ b/lib/jekyll/renderer.rb @@ -0,0 +1,132 @@ +module Jekyll + class Renderer + + attr_reader :document, :site + + def initialize(site, document) + @site = site + @document = document + end + + # Determine which converters to use based on this document's + # extension. + # + # Returns an array of Converter instances. + def converters + @converters ||= site.converters.select { |c| c.matches(document.extname) } + end + + # Determine the extname the outputted file should have + # + # Returns the output extname including the leading period. + def output_ext + converters.first.output_ext(document.extname) + end + + ###################### + ## DAT RENDER THO + ###################### + + def run + payload = Utils.deep_merge_hashes({ + "page" => document.to_liquid + }, site.site_payload) + + info = { + filters: [Jekyll::Filters], + registers: { :site => site, :page => payload['page'] } + } + + # render and transform content (this becomes the final content of the object) + payload["highlighter_prefix"] = converters.first.highlighter_prefix + payload["highlighter_suffix"] = converters.first.highlighter_suffix + + output = document.content + + if document.render_with_liquid? + output = render_liquid(output, payload, info) + end + + place_in_layouts( + convert(output), + payload, + info + ) + end + + # Convert the given content using the converters which match this renderer's document. + # + # content - the raw, unconverted content + # + # Returns the converted content. + def convert(content) + converters.reduce(content) do |output, converter| + begin + converter.convert output + rescue => e + Jekyll.logger.error "Conversion error:", "#{converter.class} encountered an error converting '#{document.relative_path}'." + raise e + end + end + end + + # Render the given content with the payload and info + # + # content - + # payload - + # info - + # path - (optional) the path to the file, for use in ex + # + # Returns the content, rendered by Liquid. + def render_liquid(content, payload, info, path = nil) + Liquid::Template.parse(content).render!(payload, info) + rescue Tags::IncludeTagError => e + Jekyll.logger.error "Liquid Exception:", "#{e.message} in #{e.path}, included in #{path || document.relative_path}" + raise e + rescue Exception => e + Jekyll.logger.error "Liquid Exception:", "#{e.message} in #{path || document.relative_path}" + raise e + end + + # Render layouts and place given content inside. + # + # content - the content to be placed in the layout + # + # + # Returns the content placed in the Liquid-rendered layouts + def place_in_layouts(content, payload, info) + output = content.dup + layout = site.layouts[document.data["layout"]] + used = Set.new([layout]) + + while layout + payload = Utils.deep_merge_hashes( + payload, + { + "content" => output, + "page" => document.to_liquid, + "layout" => layout.data + } + ) + + output = render_liquid( + layout.content, + payload, + info, + File.join(site.config['layouts'], layout.name) + ) + + if layout = site.layouts[layout.data["layout"]] + if used.include?(layout) + layout = nil # avoid recursive chain + else + used << layout + end + end + end + + output + end + + end +end diff --git a/lib/jekyll/site.rb b/lib/jekyll/site.rb index 14224898d..92e5e3594 100644 --- a/lib/jekyll/site.rb +++ b/lib/jekyll/site.rb @@ -4,7 +4,7 @@ module Jekyll :exclude, :include, :source, :dest, :lsi, :highlighter, :permalink_style, :time, :future, :unpublished, :safe, :plugins, :limit_posts, :show_drafts, :keep_files, :baseurl, :data, :file_read_opts, :gems, - :plugin_manager + :plugin_manager, :collections attr_accessor :converters, :generators @@ -14,12 +14,13 @@ module Jekyll def initialize(config) self.config = config.clone - %w[safe lsi highlighter baseurl exclude include future unpublished show_drafts limit_posts keep_files gems].each do |opt| + %w[safe lsi highlighter baseurl exclude include future unpublished + show_drafts limit_posts keep_files gems].each do |opt| self.send("#{opt}=", config[opt]) end - self.source = File.expand_path(config['source']) - self.dest = File.expand_path(config['destination']) + self.source = File.expand_path(config['source']) + self.dest = File.expand_path(config['destination']) self.permalink_style = config['permalink'].to_sym self.plugin_manager = Jekyll::PluginManager.new(self) @@ -83,6 +84,26 @@ module Jekyll end end + # The list of collections and their corresponding Jekyll::Collection instances. + # If config['collections'] is set, a new instance is created for each item in the collection. + # If config['collections'] is not set, a new hash is returned. + # + # Returns a Hash containing collection name-to-instance pairs. + def collections + @collections ||= if config['collections'] + Hash[config['collections'].map { |coll| [coll, Jekyll::Collection.new(self, coll)] } ] + else + Hash.new + end + end + + # The list of collections to render. + # + # The array of collection labels to render. + def to_render + @to_render ||= (config['render'] || Array.new) + end + # Read Site data from disk and load it into internal data structures. # # Returns nothing. @@ -90,6 +111,7 @@ module Jekyll self.layouts = LayoutReader.new(self).read read_directories read_data(config['data_source']) + read_collections end # Recursively traverse directories to find posts, pages and static files @@ -166,19 +188,25 @@ module Jekyll # # Returns nothing def read_data(dir) - base = File.join(source, dir) - return unless File.directory?(base) && (!safe || !File.symlink?(base)) - - entries = Dir.chdir(base) { Dir['*.{yaml,yml}'] } - entries.delete_if { |e| File.directory?(File.join(base, e)) } - - entries.each do |entry| - path = File.join(source, dir, entry) - next if File.symlink?(path) && safe - - key = sanitize_filename(File.basename(entry, '.*')) - self.data[key] = SafeYAML.load_file(path) + unless dir.to_s.eql?("_data") + Jekyll.logger.error "Error:", "Data source directories other than '_data' have been removed.\n" + + "Please move your YAML files to `_data` and remove the `data_source` key from your `_config.yml`." end + + collections['data'] = Jekyll::Collection.new(self, "data") + collections['data'].read + + collections['data'].docs.each do |doc| + key = sanitize_filename(doc.basename(".*")) + self.data[key] = doc.data + end + end + + # Read in all collections specified in the configuration + # + # Returns nothing. + def read_collections + collections.each { |_, collection| collection.read } end # Run each of the Generators. @@ -196,6 +224,12 @@ module Jekyll def render relative_permalinks_deprecation_method + to_render.each do |label| + collections[label].docs.each do |document| + document.output = Jekyll::Renderer.new(self, document).run + end + end + payload = site_payload [posts, pages].flatten.each do |page_or_post| page_or_post.render(layouts, payload) @@ -271,7 +305,8 @@ module Jekyll # See Site#post_attr_hash for type info. def site_payload {"jekyll" => { "version" => Jekyll::VERSION }, - "site" => config.merge({ + "site" => Utils.deep_merge_hashes(config, + Utils.deep_merge_hashes(collections, { "time" => time, "posts" => posts.sort { |a, b| b <=> a }, "pages" => pages, @@ -279,7 +314,9 @@ module Jekyll "html_pages" => pages.reject { |page| !page.html? }, "categories" => post_attr_hash('categories'), "tags" => post_attr_hash('tags'), - "data" => site_data})} + "data" => site_data + })) + } end # Filter out any files/directories that are hidden or backup files (start @@ -357,8 +394,18 @@ module Jekyll end end + def documents + collections.reduce(Set.new) do |docs, (label, coll)| + if to_render.include?(label) + docs.merge(coll.docs) + else + docs + end + end + end + def each_site_file - %w(posts pages static_files).each do |type| + %w(posts pages static_files documents).each do |type| send(type).each do |item| yield item end diff --git a/lib/jekyll/url.rb b/lib/jekyll/url.rb index 66b4412d6..8cd47242d 100644 --- a/lib/jekyll/url.rb +++ b/lib/jekyll/url.rb @@ -24,9 +24,9 @@ module Jekyll # template. Instead, the given permalink will be # used as URL. def initialize(options) - @template = options[:template] + @template = options[:template] @placeholders = options[:placeholders] || {} - @permalink = options[:permalink] + @permalink = options[:permalink] if (@template || @permalink).nil? raise ArgumentError, "One of :template or :permalink must be supplied." diff --git a/script/console b/script/console new file mode 100755 index 000000000..34ad6e8a3 --- /dev/null +++ b/script/console @@ -0,0 +1,38 @@ +#!/usr/bin/env ruby + +require 'pry' +$LOAD_PATH.unshift File.join(File.dirname(__FILE__), *%w{ .. lib }) +require 'jekyll' + +TEST_DIR = File.expand_path(File.join(File.dirname(__FILE__), *%w{ .. test })) + +def fixture_site(overrides = {}) + Jekyll::Site.new(site_configuration(overrides)) +end + +def build_configs(overrides, base_hash = Jekyll::Configuration::DEFAULTS) + Jekyll::Utils.deep_merge_hashes(base_hash, overrides) +end + +def site_configuration(overrides = {}) + build_configs({ + "source" => source_dir, + "destination" => dest_dir + }, build_configs(overrides)) +end + +def dest_dir(*subdirs) + test_dir('dest', *subdirs) +end + +def source_dir(*subdirs) + test_dir('source', *subdirs) +end + +def test_dir(*subdirs) + File.join(TEST_DIR, *subdirs) +end + +module Jekyll + binding.pry +end diff --git a/site/_data/docs.yml b/site/_data/docs.yml index 1c7906364..7d0a83f3f 100644 --- a/site/_data/docs.yml +++ b/site/_data/docs.yml @@ -14,6 +14,7 @@ - drafts - pages - variables + - collections - datafiles - assets - migrations diff --git a/site/docs/collections.md b/site/docs/collections.md new file mode 100644 index 000000000..23ac190a8 --- /dev/null +++ b/site/docs/collections.md @@ -0,0 +1,126 @@ +--- +layout: docs +title: Collections +prev_section: variables +next_section: datafiles +permalink: /docs/collections/ +--- + +
+
Collections support is currently unreleased.
+

+ In order to use this feature, + install the latest development version of Jekyll. +

+
+ +
+
Collections support is unstable and may change
+

+ This is an experimental feature and that the API may likely change until the feature stabilizes. +

+
+ +Put some things in a folder and add the folder to your config. It's simple... + +Not everything is a post or a page. Maybe you want to document the various methods in your open source project, members of a team, or talks at a conference. Collections allow you to define a new type of document that behave like Pages or Posts do normally, but also have their own unique properties and namespace. + +## Using Collections + +### Step 1: Tell Jekyll to read in your collection + +Add the following to your site's `_config.yml` file, replacing `my_collection` with the name of your collection: + +{% highlight yaml %} +collections: +- my_collection +{% endhighlight %} + +### Step 2: Add your content + +Create a corresponding folder (e.g. `/_my_collection`) and add documents. +YAML front-matter is read in as data if it exists, if not, then everything is just stuck in the Document's `content` attribute. + +Note: the folder must be named identical to the collection you defined in you config.yml file, with the addition of the preceding `_` character. + +### Step 3: Optionally render your collection's documents into independent files + +If you'd like Jekyll to create a public-facing, rendered version of each document in your collection, add your collection name to the `render` config key in your `_config.yml`: + +{% highlight yaml %} +render: +- my_collection +{% endhighlight %} + +This will produce a file for each document in the collection. +For example, if you have `_my_collection/some_subdir/some_doc.md`, +it will be rendered using Liquid and the Markdown converter of your +choice and written out to `/my_collection/some_subdir/some_doc.html`. + +## Liquid Attributes + +### Collections + +Each collection is accessible via the `site` Liquid variable. For example, if you want to access the `albums` collection found in `_albums`, you'd use `site.albums`. Each collection is itself an array of documents (e.g. `site.albums` is an array of documents, much like `site.pages` and `site.posts`). See below for how to access attributes of those documents. + +### Documents + +In addition to any YAML front-matter provided in the document's corresponding file, each document has the following attributes: + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
VariableDescription
+

content

+
+

+ The content of the document. If no YAML front-matter is provided, + this is the entirety of the file contents. If YAML front-matter + is used, then this is all the contents of the file after the terminating + `---` of the front-matter. +

+
+

path

+
+

+ The full path to the document's source file. +

+
+

relative_path

+
+

+ The path to the document's source file relative to the site source. +

+
+

url

+
+

+ The URL of the rendered collection. The file is only written to the + destination when the name of the collection to which it belongs is + included in the render key in the site's configuration file. +

+
+
diff --git a/site/docs/datafiles.md b/site/docs/datafiles.md index 55af6ec43..1dbe40972 100644 --- a/site/docs/datafiles.md +++ b/site/docs/datafiles.md @@ -1,7 +1,7 @@ --- layout: docs title: Data Files -prev_section: variables +prev_section: collections next_section: assets permalink: /docs/datafiles/ --- diff --git a/site/docs/variables.md b/site/docs/variables.md index 2c6d38492..d6c7390b4 100644 --- a/site/docs/variables.md +++ b/site/docs/variables.md @@ -2,7 +2,7 @@ layout: docs title: Variables prev_section: pages -next_section: datafiles +next_section: collections permalink: /docs/variables/ --- diff --git a/test/source/_methods/_do_not_read_me.md b/test/source/_methods/_do_not_read_me.md new file mode 100644 index 000000000..1b5ad07d0 --- /dev/null +++ b/test/source/_methods/_do_not_read_me.md @@ -0,0 +1,5 @@ +--- +title: The unreadable wonder +--- + +Don't read me, you fool! FILTER ME diff --git a/test/source/_methods/configuration.md b/test/source/_methods/configuration.md new file mode 100644 index 000000000..fd17980b4 --- /dev/null +++ b/test/source/_methods/configuration.md @@ -0,0 +1,8 @@ +--- +title: "Jekyll.configuration" +whatever: foo.bar +--- + +Use `{{ page.title }}` to build a full configuration for use w/Jekyll. + +Whatever: {{ page.whatever }} diff --git a/test/source/_methods/sanitized_path.md b/test/source/_methods/sanitized_path.md new file mode 100644 index 000000000..8b4d767a8 --- /dev/null +++ b/test/source/_methods/sanitized_path.md @@ -0,0 +1,5 @@ +--- +title: "Jekyll.sanitized_path" +--- + +`{{ page.title }}` is used to make sure your path is in your source. diff --git a/test/source/_methods/site/_dont_include_me_either.md b/test/source/_methods/site/_dont_include_me_either.md new file mode 100644 index 000000000..66079613e --- /dev/null +++ b/test/source/_methods/site/_dont_include_me_either.md @@ -0,0 +1,5 @@ +--- +title: Don't Include Me Either +--- + +Don't include me either. FILTER ME PLZ diff --git a/test/source/_methods/site/generate.md b/test/source/_methods/site/generate.md new file mode 100644 index 000000000..1cab376ee --- /dev/null +++ b/test/source/_methods/site/generate.md @@ -0,0 +1,6 @@ +--- +title: "Site#generate" +layout: default +--- + +Run your generators! {{ page.layout }} diff --git a/test/source/_methods/site/initialize.md b/test/source/_methods/site/initialize.md new file mode 100644 index 000000000..9c23b967f --- /dev/null +++ b/test/source/_methods/site/initialize.md @@ -0,0 +1,5 @@ +--- +title: "Site#initialize" +--- + +Create dat site. diff --git a/test/source/_methods/um_hi.md b/test/source/_methods/um_hi.md new file mode 120000 index 000000000..9ebb53251 --- /dev/null +++ b/test/source/_methods/um_hi.md @@ -0,0 +1 @@ +./site/generate.md \ No newline at end of file diff --git a/test/test_collections.rb b/test/test_collections.rb new file mode 100644 index 000000000..8b4c2d230 --- /dev/null +++ b/test/test_collections.rb @@ -0,0 +1,129 @@ +require 'helper' + +class TestCollections < Test::Unit::TestCase + + def fixture_site(overrides = {}) + Jekyll::Site.new(Jekyll.configuration( + overrides.merge({ + "source" => source_dir, + "destination" => dest_dir + }) + )) + end + + context "an evil collection" do + setup do + @collection = Jekyll::Collection.new(fixture_site, "../../etc/password") + end + + should "sanitize the label name" do + assert_equal @collection.label, "etcpassword" + end + + should "have a sanitized relative path name" do + assert_equal @collection.relative_directory, "_etcpassword" + end + + should "have a sanitized full path" do + assert_equal @collection.directory, source_dir("_etcpassword") + end + end + + context "a simple collection" do + setup do + @collection = Jekyll::Collection.new(fixture_site, "methods") + end + + should "sanitize the label name" do + assert_equal @collection.label, "methods" + end + + should "contain no docs when initialized" do + assert_empty @collection.docs + end + + should "know its relative directory" do + assert_equal @collection.relative_directory, "_methods" + end + + should "know the full path to itself on the filesystem" do + assert_equal @collection.directory, source_dir("_methods") + end + end + + context "with no collections specified" do + setup do + @site = fixture_site + @site.process + end + + should "not contain any collections other than the default ones" do + collections = @site.collections.dup + assert collections.delete("data").is_a?(Jekyll::Collection) + assert_equal Hash.new, collections + end + end + + context "with a collection" do + setup do + @site = fixture_site({ + "collections" => ["methods"] + }) + @site.process + @collection = @site.collections["methods"] + end + + should "create a Hash on Site with the label mapped to the instance of the Collection" do + assert @site.collections.is_a?(Hash) + assert_not_nil @site.collections["methods"] + assert @site.collections["methods"].is_a? Jekyll::Collection + end + + should "collects docs in an array on the Collection object" do + assert @site.collections["methods"].docs.is_a? Array + @site.collections["methods"].docs.each do |doc| + assert doc.is_a? Jekyll::Document + assert_include %w[ + _methods/configuration.md + _methods/sanitized_path.md + _methods/site/generate.md + _methods/site/initialize.md + _methods/um_hi.md + ], doc.relative_path + end + end + + should "not include files which start with an underscore in the base collection directory" do + assert_not_include @collection.filtered_entries, "_do_not_read_me.md" + end + + should "not include files which start with an underscore in a subdirectory" do + assert_not_include @collection.filtered_entries, "site/_dont_include_me_either.md" + end + + should "not include the underscored files in the list of docs" do + assert_not_include @collection.docs.map(&:relative_path), "_methods/_do_not_read_me.md" + assert_not_include @collection.docs.map(&:relative_path), "_methods/site/_dont_include_me_either.md" + end + end + + context "in safe mode" do + setup do + @site = fixture_site({ + "collections" => ["methods"], + "safe" => true + }) + @site.process + @collection = @site.collections["methods"] + end + + should "not allow symlinks" do + assert_not_include @collection.filtered_entries, "um_hi.md" + end + + should "not include the symlinked file in the list of docs" do + assert_not_include @collection.docs.map(&:relative_path), "_methods/um_hi.md" + end + end + +end diff --git a/test/test_document.rb b/test/test_document.rb new file mode 100644 index 000000000..794c2117a --- /dev/null +++ b/test/test_document.rb @@ -0,0 +1,48 @@ +require 'helper' + +class TestDocument < Test::Unit::TestCase + + context "a document in a collection" do + setup do + @site = Site.new(Jekyll.configuration({ + "collections" => ["methods"], + "source" => source_dir, + "destination" => dest_dir + })) + @site.process + @document = @site.collections["methods"].docs.first + end + + should "know its relative path" do + assert_equal "_methods/configuration.md", @document.relative_path + end + + should "knows its extname" do + assert_equal ".md", @document.extname + end + + should "know its basename" do + assert_equal "configuration.md", @document.basename + end + + should "allow the suffix to be specified for the basename" do + assert_equal "configuration", @document.basename(".*") + end + + should "know whether its a yaml file" do + assert_equal false, @document.yaml_file? + end + + should "know its data" do + assert_equal({ + "title" => "Jekyll.configuration", + "whatever" => "foo.bar" + }, @document.data) + end + + end + + context " a document part of a rendered collection" do + end + +end