# 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