310 lines
		
	
	
		
			9.1 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			310 lines
		
	
	
		
			9.1 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
| # frozen_string_literal: true
 | |
| 
 | |
| module Jekyll
 | |
|   class Collection
 | |
|     attr_reader :site, :label, :metadata
 | |
|     attr_writer :docs
 | |
| 
 | |
|     # 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)
 | |
|       @metadata = extract_metadata
 | |
|     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
 | |
| 
 | |
|     # Override of normal respond_to? to match method_missing's logic for
 | |
|     # looking in @data.
 | |
|     def respond_to_missing?(method, include_private = false)
 | |
|       docs.respond_to?(method.to_sym, include_private) || super
 | |
|     end
 | |
| 
 | |
|     # Override of method_missing to check in @data for the key.
 | |
|     def method_missing(method, *args, &blck)
 | |
|       if docs.respond_to?(method.to_sym)
 | |
|         Jekyll.logger.warn "Deprecation:",
 | |
|                            "#{label}.#{method} should be changed to #{label}.docs.#{method}."
 | |
|         Jekyll.logger.warn "", "Called by #{caller(0..0)}."
 | |
|         docs.public_send(method.to_sym, *args, &blck)
 | |
|       else
 | |
|         super
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     # Fetch the static files in this collection.
 | |
|     # Defaults to an empty array if no static files have been read in.
 | |
|     #
 | |
|     # Returns an array of Jekyll::StaticFile objects.
 | |
|     def files
 | |
|       @files ||= []
 | |
|     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|
 | |
|         full_path = collection_dir(file_path)
 | |
|         next if File.directory?(full_path)
 | |
| 
 | |
|         if Utils.has_yaml_header? full_path
 | |
|           read_document(full_path)
 | |
|         else
 | |
|           read_static_file(file_path, full_path)
 | |
|         end
 | |
|       end
 | |
|       sort_docs!
 | |
|     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 [] unless exists?
 | |
| 
 | |
|       @entries ||= begin
 | |
|         collection_dir_slash = "#{collection_dir}/"
 | |
|         Utils.safe_glob(collection_dir, ["**", "*"], File::FNM_DOTMATCH).map do |entry|
 | |
|           entry[collection_dir_slash] = ""
 | |
|           entry
 | |
|         end
 | |
|       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 [] unless exists?
 | |
| 
 | |
|       @filtered_entries ||=
 | |
|         Dir.chdir(directory) do
 | |
|           entry_filter.filter(entries).reject do |f|
 | |
|             path = collection_dir(f)
 | |
|             File.directory?(path) || entry_filter.symlink?(f)
 | |
|           end
 | |
|         end
 | |
|     end
 | |
| 
 | |
|     # The directory for this Collection, relative to the site source or the directory
 | |
|     # containing the collection.
 | |
|     #
 | |
|     # Returns a String containing the directory name where the collection
 | |
|     #   is stored on the filesystem.
 | |
|     def relative_directory
 | |
|       @relative_directory ||= "_#{label}"
 | |
|     end
 | |
| 
 | |
|     # The full path to the directory containing the collection.
 | |
|     #
 | |
|     # Returns a String containing th directory name where the collection
 | |
|     #   is stored on the filesystem.
 | |
|     def directory
 | |
|       @directory ||= site.in_source_dir(
 | |
|         File.join(container, relative_directory)
 | |
|       )
 | |
|     end
 | |
| 
 | |
|     # The full path to the directory containing the collection, with
 | |
|     #   optional subpaths.
 | |
|     #
 | |
|     # *files - (optional) any other path pieces relative to the
 | |
|     #           directory to append to the path
 | |
|     #
 | |
|     # Returns a String containing th directory name where the collection
 | |
|     #   is stored on the filesystem.
 | |
|     def collection_dir(*files)
 | |
|       return directory if files.empty?
 | |
| 
 | |
|       site.in_source_dir(container, relative_directory, *files)
 | |
|     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) && !entry_filter.symlink?(directory)
 | |
|     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
 | |
|       "#<#{self.class} @label=#{label} docs=#{docs}>"
 | |
|     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(%r![^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
 | |
|       Drops::CollectionDrop.new self
 | |
|     end
 | |
| 
 | |
|     # Whether the collection's documents ought to be written as individual
 | |
|     #   files in the output.
 | |
|     #
 | |
|     # Returns true if the 'write' metadata is true, false otherwise.
 | |
|     def write?
 | |
|       !!metadata.fetch("output", false)
 | |
|     end
 | |
| 
 | |
|     # The URL template to render collection's documents at.
 | |
|     #
 | |
|     # Returns the URL template to render collection's documents at.
 | |
|     def url_template
 | |
|       @url_template ||= metadata.fetch("permalink") do
 | |
|         Utils.add_permalink_suffix("/:collection/:path", site.permalink_style)
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     # Extract options for this collection from the site configuration.
 | |
|     #
 | |
|     # Returns the metadata for this collection
 | |
|     def extract_metadata
 | |
|       if site.config["collections"].is_a?(Hash)
 | |
|         site.config["collections"][label] || {}
 | |
|       else
 | |
|         {}
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     private
 | |
| 
 | |
|     def container
 | |
|       @container ||= site.config["collections_dir"]
 | |
|     end
 | |
| 
 | |
|     def read_document(full_path)
 | |
|       doc = Document.new(full_path, :site => site, :collection => self)
 | |
|       doc.read
 | |
|       docs << doc if site.unpublished || doc.published?
 | |
|     end
 | |
| 
 | |
|     def sort_docs!
 | |
|       if metadata["order"].is_a?(Array)
 | |
|         rearrange_docs!
 | |
|       elsif metadata["sort_by"].is_a?(String)
 | |
|         sort_docs_by_key!
 | |
|       else
 | |
|         docs.sort!
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     # A custom sort function based on Schwartzian transform
 | |
|     # Refer https://byparker.com/blog/2017/schwartzian-transform-faster-sorting/ for details
 | |
|     def sort_docs_by_key!
 | |
|       meta_key = metadata["sort_by"]
 | |
|       # Modify `docs` array to cache document's property along with the Document instance
 | |
|       docs.map! { |doc| [doc.data[meta_key], doc] }.sort! do |apples, olives|
 | |
|         order = determine_sort_order(meta_key, apples, olives)
 | |
| 
 | |
|         # Fall back to `Document#<=>` if the properties were equal or were non-sortable
 | |
|         # Otherwise continue with current sort-order
 | |
|         if order.zero? || order.nil?
 | |
|           apples[-1] <=> olives[-1]
 | |
|         else
 | |
|           order
 | |
|         end
 | |
| 
 | |
|         # Finally restore the `docs` array with just the Document objects themselves
 | |
|       end.map!(&:last)
 | |
|     end
 | |
| 
 | |
|     def determine_sort_order(sort_key, apples, olives)
 | |
|       apple_property, apple_document = apples
 | |
|       olive_property, olive_document = olives
 | |
| 
 | |
|       if apple_property.nil? && !olive_property.nil?
 | |
|         order_with_warning(sort_key, apple_document, 1)
 | |
|       elsif !apple_property.nil? && olive_property.nil?
 | |
|         order_with_warning(sort_key, olive_document, -1)
 | |
|       else
 | |
|         apple_property <=> olive_property
 | |
|       end
 | |
|     end
 | |
| 
 | |
|     def order_with_warning(sort_key, document, order)
 | |
|       Jekyll.logger.warn "Sort warning:", "'#{sort_key}' not defined in #{document.relative_path}"
 | |
|       order
 | |
|     end
 | |
| 
 | |
|     # Rearrange documents within the `docs` array as listed in the `metadata["order"]` array.
 | |
|     #
 | |
|     # Involves converting the two arrays into hashes based on relative_paths as keys first, then
 | |
|     # merging them to remove duplicates and finally retrieving the Document instances from the
 | |
|     # merged array.
 | |
|     def rearrange_docs!
 | |
|       docs_table   = {}
 | |
|       custom_order = {}
 | |
| 
 | |
|       # pre-sort to normalize default array across platforms and then proceed to create a Hash
 | |
|       # from that sorted array.
 | |
|       docs.sort.each do |doc|
 | |
|         docs_table[doc.relative_path] = doc
 | |
|       end
 | |
| 
 | |
|       metadata["order"].each do |entry|
 | |
|         custom_order[File.join(relative_directory, entry)] = nil
 | |
|       end
 | |
| 
 | |
|       result = Jekyll::Utils.deep_merge_hashes(custom_order, docs_table).values
 | |
|       result.compact!
 | |
|       self.docs = result
 | |
|     end
 | |
| 
 | |
|     def read_static_file(file_path, full_path)
 | |
|       relative_dir = Jekyll.sanitized_path(
 | |
|         relative_directory,
 | |
|         File.dirname(file_path)
 | |
|       ).chomp("/.")
 | |
| 
 | |
|       files << StaticFile.new(
 | |
|         site,
 | |
|         site.source,
 | |
|         relative_dir,
 | |
|         File.basename(full_path),
 | |
|         self
 | |
|       )
 | |
|     end
 | |
|   end
 | |
| end
 |