jekyll_all_collections 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ module AllCollectionsHooks
2
+ class APage
3
+ attr_reader :content, :data, :date, :description, :destination, :draft, :excerpt, :ext, :extname, :href,
4
+ :label, :last_modified, :layout, :origin, :path, :relative_path, :tags, :title, :type, :url
5
+
6
+ def initialize(obj, origin)
7
+ @origin = origin
8
+ data_field_init obj
9
+ obj_field_init obj
10
+ @draft = Jekyll::Draft.draft? obj
11
+ @href = @url if @href.nil?
12
+ # @href = "/#{@href}" if @origin == 'individual_page'
13
+ @href = "#{@href}index.html" if @href.end_with? '/'
14
+ @name = File.basename(@href)
15
+ @title = if @data&.key?('title')
16
+ @data['title']
17
+ elsif obj.respond_to?(:title)
18
+ obj.title
19
+ else
20
+ "<code>#{@href}</code>"
21
+ end
22
+ rescue StandardError => e
23
+ JekyllSupport.error_short_trace(@logger, e)
24
+ # JekyllSupport.warn_short_trace(@logger, e)
25
+ end
26
+
27
+ def to_s
28
+ @label || @date.to_s
29
+ end
30
+
31
+ private
32
+
33
+ def data_field_init(obj)
34
+ return unless obj.respond_to? :data
35
+
36
+ @data = obj.data
37
+
38
+ @categories = @data['categories'] if @data.key? 'categories'
39
+ @date = (@data['date'].to_date if @data&.key?('date')) || Date.today
40
+ @description = @data['description'] if @data.key? 'description'
41
+ @excerpt = @data['excerpt'] if @data.key? 'excerpt'
42
+ @ext ||= @data['ext'] if @data.key? 'ext'
43
+ @last_modified = @data['last_modified'] || @data['last_modified_at'] || @date
44
+ @last_modified_field = case @data
45
+ when @data.key?('last_modified')
46
+ 'last_modified'
47
+ when @data.key?('last_modified_at')
48
+ 'last_modified_at'
49
+ end
50
+ @layout = @data['layout'] if @data.key? 'layout'
51
+ @tags = @data['tags'] if @data.key? 'tags'
52
+ end
53
+
54
+ def obj_field_init(obj)
55
+ @content = obj.content if obj.respond_to? :content
56
+
57
+ # TODO: What _config.yml setting should be passed to destination()?
58
+ @destination = obj.destination('') if obj.respond_to? :destination
59
+ @ext = obj.extname
60
+ @extname = @ext # For compatibility with previous versions of all_collections
61
+ @label = obj.collection.label if obj.respond_to?(:collection) && obj.collection.respond_to?(:label)
62
+ @path = obj.path if obj.respond_to? :path
63
+ @relative_path = obj.relative_path if obj.respond_to? :relative_path
64
+ @type = obj.type if obj.respond_to? :type
65
+ @url = obj.url
66
+ @url = "#{@url}index.html" if @url.end_with? '/'
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,59 @@
1
+ module AllCollectionsHooks
2
+ class << self
3
+ attr_accessor :logger
4
+ end
5
+ @logger = PluginMetaLogger.instance.new_logger(self, PluginMetaLogger.instance.config)
6
+
7
+ # No, all_collections is not defined for this hook
8
+ # Jekyll::Hooks.register(:site, :after_init, priority: :normal) do |site|
9
+ # defined_msg = AllCollectionsHooks.all_collections_defined?(site)
10
+ # @logger.debug { "Jekyll::Hooks.register(:site, :after_init: #{defined_msg}" }
11
+ # end
12
+
13
+ # Creates a `Array[APage]` property called site.all_collections if it does not already exist.
14
+ # The array is available from :site, :pre_render onwards
15
+ # Each `APage` entry is one document or page.
16
+ Jekyll::Hooks.register(:site, :post_read, priority: :normal) do |site|
17
+ @site = site
18
+ unless site.class.method_defined? :all_collections
19
+ defined_msg = AllCollectionsHooks.all_collections_defined? site
20
+ @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :normal: #{defined_msg}" }
21
+ AllCollectionsHooks.compute(site) if !@site.class.method_defined?(:all_documents) || @site.all_documents.nil?
22
+ end
23
+ rescue StandardError => e
24
+ JekyllSupport.error_short_trace(@logger, e)
25
+ # JekyllSupport.warn_short_trace(@logger, e)
26
+ end
27
+
28
+ # Yes, all_collections is defined for this hook
29
+ # Jekyll::Hooks.register(:site, :post_read, priority: :low) do |site|
30
+ # defined_msg = AllCollectionsHooks.all_collections_defined?(site)
31
+ # @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :low: #{defined_msg}" }
32
+ # rescue StandardError => e
33
+ # JekyllSupport.error_short_trace(@logger, e)
34
+ # # JekyllSupport.warn_short_trace(@logger, e)
35
+ # end
36
+
37
+ # Yes, all_collections is defined for this hook
38
+ # Jekyll::Hooks.register(:site, :post_read, priority: :normal) do |site|
39
+ # defined_msg = AllCollectionsHooks.all_collections_defined?(site)
40
+ # @logger.debug { "Jekyll::Hooks.register(:site, :post_read, :normal: #{defined_msg}" }
41
+ # rescue StandardError => e
42
+ # JekyllSupport.error_short_trace(@logger, e)
43
+ # # JekyllSupport.warn_short_trace(@logger, e)
44
+ # end
45
+
46
+ # Yes, all_collections is defined for this hook
47
+ # Jekyll::Hooks.register(:site, :pre_render, priority: :normal) do |site, _payload|
48
+ # defined_msg = AllCollectionsHooks.all_collections_defined?(site)
49
+ # @logger.debug { "Jekyll::Hooks.register(:site, :pre_render: #{defined_msg}" }
50
+ # rescue StandardError => e
51
+ # JekyllSupport.error_short_trace(@logger, e)
52
+ # # JekyllSupport.warn_short_trace(@logger, e)
53
+ # end
54
+ end
55
+
56
+ PluginMetaLogger.instance.logger.info do
57
+ "Loaded AllCollectionsHooks v#{JekyllAllCollectionsVersion::VERSION} :site, :pre_render, :normal hook plugin."
58
+ end
59
+ Liquid::Template.register_filter(AllCollectionsHooks)
@@ -0,0 +1,46 @@
1
+ require_relative '../util/send_chain'
2
+
3
+ # Insert the url of a Jekyll::Page into each LruFile instance,
4
+ # along with the Page reference
5
+ # todo: replace references to url and :url with reverse_url and :reverse_url
6
+ LruFile = Struct.new(:url, :page) do
7
+ include SendChain
8
+
9
+ def <=>(other)
10
+ url <=> other.url
11
+ end
12
+ end
13
+
14
+ # Matches suffixes of an array of urls
15
+ # Converts suffixes to prefixes
16
+ class SortedLruFiles
17
+ attr_reader :msbs
18
+
19
+ def initialize
20
+ @msbs = MSlinnBinarySearch.new %i[url start_with?]
21
+ end
22
+
23
+ # @param apages [Array[APage]]
24
+ def add_pages(apages)
25
+ apages.each { |apage| insert apage.href, apage }
26
+ @msbs.enable_search
27
+ end
28
+
29
+ def enable_search
30
+ @msbs.enable_search
31
+ end
32
+
33
+ def find(suffix)
34
+ @msbs.find suffix
35
+ end
36
+
37
+ def insert(url, file)
38
+ lru_file = LruFile.new(url.reverse, file)
39
+ lru_file.new_chain [:url, %i[start_with? placeholder]]
40
+ @msbs.insert(lru_file)
41
+ end
42
+
43
+ def select(suffix)
44
+ @msbs.select_pages suffix
45
+ end
46
+ end
@@ -0,0 +1,50 @@
1
+ module AllCollectionsHooks
2
+ class << self
3
+ attr_accessor :all_collections, :all_documents, :everything, :sorted_lru_files
4
+ end
5
+
6
+ # @sort_by = ->(apages, criteria) { [apages.sort(criteria)] } # todo delete this
7
+
8
+ # @return [String] indicating if :all_collections is defined or not
9
+ def self.all_collections_defined?(site)
10
+ "site.all_collections #{site.class.method_defined?(:all_collections) ? 'IS' : 'IS NOT'} defined"
11
+ end
12
+
13
+ # Create Array of AllCollectionsHooks::APage from objects
14
+ # @param objects [Array] An array of Jekyll::Document, Jekyll::Page or file names
15
+ # @param origin [String] Indicates type of objects being passed
16
+ def self.apages_from_objects(objects, origin)
17
+ pages = []
18
+ objects.each do |object|
19
+ page = APage.new(object, origin)
20
+ pages << page unless page.data['exclude_from_all'] || page.path == 'redirect.html'
21
+ end
22
+ pages
23
+ end
24
+
25
+ # Called by early, high-priority hook.
26
+ # Computes site.all_collections, site.all_documents, site.everything, and site.sorted_lru_files
27
+ def self.compute(site)
28
+ site.class.module_eval { attr_accessor :all_collections, :all_documents, :everything, :sorted_lru_files }
29
+
30
+ documents = site.collections
31
+ .values
32
+ .map { |x| x.class.method_defined?(:docs) ? x.docs : x }
33
+ .flatten
34
+ .compact
35
+ @all_collections = AllCollectionsHooks.apages_from_objects(documents, 'collection')
36
+ @all_documents = @all_collections +
37
+ AllCollectionsHooks.apages_from_objects(site.pages, 'individual_page')
38
+ @everything = @all_documents +
39
+ AllCollectionsHooks.apages_from_objects(site.static_files, 'static_file')
40
+ @sorted_lru_files = SortedLruFiles.new.add_pages @everything
41
+
42
+ site.all_collections = @all_collections
43
+ site.all_documents = @all_documents
44
+ site.everything = @everything
45
+ site.sorted_lru_files = @sorted_lru_files
46
+ rescue StandardError => e
47
+ JekyllSupport.error_short_trace(AllCollectionsHooks.logger, e)
48
+ # JekyllSupport.warn_short_trace(AllCollectionsHooks.logger, e)
49
+ end
50
+ end
@@ -1,3 +1,3 @@
1
1
  module JekyllAllCollectionsVersion
2
- VERSION = '0.3.7'.freeze
2
+ VERSION = '0.4.0'.freeze
3
3
  end
@@ -1,5 +1,17 @@
1
- require_relative 'all_collections_hooks'
2
- require_relative 'all_collections_tag'
1
+ def require_directory(dir)
2
+ Dir[File.join(dir, '*.rb')]&.sort&.each do |file|
3
+ require file unless file == __FILE__
4
+ end
5
+ end
6
+
7
+ require 'jekyll'
8
+ require 'jekyll_plugin_logger'
9
+ require 'jekyll_plugin_support'
10
+
11
+ require_relative 'jekyll_all_collections/version'
12
+ require_directory "#{__dir__}/hooks"
13
+ require_directory "#{__dir__}/tag"
14
+ require_directory "#{__dir__}/util"
3
15
 
4
16
  module JekyllAllCollections
5
17
  include AllCollectionsHooks
@@ -0,0 +1,158 @@
1
+ require 'jekyll_draft'
2
+ require 'jekyll_plugin_support'
3
+ require 'securerandom'
4
+
5
+ # @author Copyright 2020 Michael Slinn
6
+ # @license SPDX-License-Identifier: Apache-2.0
7
+ module AllCollectionsTag
8
+ PLUGIN_NAME = 'all_collections'.freeze
9
+ CRITERIA = %w[date destination draft label last_modified last_modified_at path relative_path title type url].freeze
10
+ DRAFT_HTML = '<i class="jekyll_draft">Draft</i>'.freeze
11
+
12
+ class AllCollectionsTag < JekyllSupport::JekyllTag
13
+ include JekyllAllCollectionsVersion
14
+
15
+ # Method prescribed by JekyllTag.
16
+ # @return [String]
17
+ def render_impl
18
+ parse_arguments # Defines instance variables like @sort_by
19
+ sort_lambda = init_sort_by @sort_by, @sort_by_param
20
+ @heading = @helper.parameter_specified?('heading') || default_head(@sort_by)
21
+ generate_output sort_lambda
22
+ rescue StandardError => e
23
+ JekyllSupport.error_short_trace @logger, e
24
+ # JekyllSupport.warn_short_trace @logger, e
25
+ end
26
+
27
+ # Descending sort keys reverse the order of comparison
28
+ # Example return values:
29
+ # "->(a, b) { [a.last_modified] <=> [b.last_modified] }"
30
+ # "->(a, b) { [b.last_modified] <=> [a.last_modified] }" (descending)
31
+ # "->(a, b) { [a.last_modified, a.date] <=> [b.last_modified, b.date] }" (descending last_modified, ascending date)
32
+ # "->(a, b) { [a.last_modified, b.date] <=> [b.last_modified, a.date] }" (ascending last_modified, descending date)
33
+ def self.create_lambda_string(criteria)
34
+ criteria_lhs_array = []
35
+ criteria_rhs_array = []
36
+ verify_sort_by_type(criteria).each do |c|
37
+ descending_sort = c.start_with? '-'
38
+ c.delete_prefix! '-'
39
+ abort("Error: '#{c}' is not a valid sort field. Valid field names are: #{CRITERIA.join ', '}") \
40
+ unless CRITERIA.include?(c)
41
+ criteria_lhs_array << (descending_sort ? "b.#{c}" : "a.#{c}")
42
+ criteria_rhs_array << (descending_sort ? "a.#{c}" : "b.#{c}")
43
+ end
44
+ "->(a, b) { [#{criteria_lhs_array.join(', ')}] <=> [#{criteria_rhs_array.join(', ')}] }"
45
+ end
46
+
47
+ def self.verify_sort_by_type(sort_by)
48
+ case sort_by
49
+ when Array
50
+ sort_by
51
+ when Enumerable
52
+ sort_by.to_a
53
+ when Date
54
+ [sort_by.to_i]
55
+ when String
56
+ [sort_by]
57
+ else
58
+ abort "Error: @sort_by was specified as '#{sort_by}'; it must either be a string or an array of strings"
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def default_head(sort_by)
65
+ criteria = (sort_by.map do |x|
66
+ reverse = x.start_with? '-'
67
+ criterion = x.delete_prefix('-').capitalize
68
+ criterion += reverse ? ' (Newest to Oldest)' : ' (Oldest to Newest)'
69
+ criterion
70
+ end).join(', ')
71
+ "All Posts in All Categories Sorted By #{criteria}"
72
+ end
73
+
74
+ def evaluate(string)
75
+ self.eval string, binding, __FILE__, __LINE__
76
+ rescue StandardError => e
77
+ warn_short_trace e.red
78
+ end
79
+
80
+ def last_modified_value(apage)
81
+ @logger.debug do
82
+ " apage.last_modified='#{apage.last_modified}'; " \
83
+ "apage.last_modified_at='#{apage.last_modified_at}'; " \
84
+ "@date_column='#{@date_column}'"
85
+ end
86
+ last_modified = if @date_column == 'last_modified' && apage.respond_to?(:last_modified)
87
+ apage.last_modified
88
+ elsif apage.respond_to? :last_modified_at
89
+ apage.last_modified_at
90
+ else
91
+ apage.date
92
+ end
93
+ last_modified ||= apage.date || Date.today
94
+ last_modified
95
+ end
96
+
97
+ def generate_output(sort_lambda)
98
+ id = @id.to_s.strip.empty? ? '' : " id='#{@id}'"
99
+ heading = @heading.strip.to_s.empty? ? '' : "<h2#{id}>#{@heading}</h2>"
100
+ data = case @data_selector
101
+ when 'all_collections'
102
+ @site.all_collections
103
+ when 'all_documents'
104
+ @site.all_documents
105
+ when 'everything'
106
+ @site.everything
107
+ else
108
+ raise AllCollectionsError, "Invalid value for @data_selector (#{data_selector})"
109
+ end
110
+ collection = data.sort(&sort_lambda)
111
+ posts = collection.map do |x|
112
+ last_modified = last_modified_value x
113
+ date = last_modified.strftime '%Y-%m-%d'
114
+ draft = x.draft ? DRAFT_HTML : ''
115
+ href = "<a href='#{x.href}'>#{x.title}</a>"
116
+ @logger.debug { " date='#{date}' #{x.title}\n" }
117
+ " <span>#{date}</span><span>#{href}#{draft}</span>"
118
+ end
119
+ <<~END_TEXT
120
+ #{heading}
121
+ <div class="posts">
122
+ #{posts.join "\n"}
123
+ </div>
124
+ END_TEXT
125
+ rescue ArgumentError => e
126
+ warn_short_trace e
127
+ end
128
+
129
+ # See https://stackoverflow.com/a/75377832/553865
130
+ def init_sort_by(sort_by, sort_by_param)
131
+ sort_lambda_string = AllCollectionsTag.create_lambda_string sort_by
132
+
133
+ @logger.debug do
134
+ "#{@page['path']} sort_by_param=#{sort_by_param} " \
135
+ "sort_lambda_string = #{sort_lambda_string}\n"
136
+ end
137
+
138
+ evaluate sort_lambda_string
139
+ end
140
+
141
+ def parse_arguments
142
+ @data_selector = @helper.parameter_specified?('data_selector') || 'all_collections'
143
+ abort "Invalid data_selector #{@data_selector}" unless %w[all_collections all_documents everything].include? @data_selector
144
+
145
+ @date_column = @helper.parameter_specified?('date_column') || 'date'
146
+ unless %w[date last_modified].include?(@date_column)
147
+ raise AllCollectionsError "The date_column attribute must either have value 'date' or 'last_modified', " \
148
+ "but '#{@date_column}' was specified"
149
+ end
150
+
151
+ @id = @helper.parameter_specified?('id') || SecureRandom.hex(10)
152
+ @sort_by_param = @helper.parameter_specified? 'sort_by'
153
+ @sort_by = (@sort_by_param&.delete(' ')&.split(',') if @sort_by_param != false) || ['-date']
154
+ end
155
+
156
+ JekyllSupport::JekyllPluginHelper.register(self, PLUGIN_NAME)
157
+ end
158
+ end
@@ -0,0 +1,152 @@
1
+ unless defined?(MSlinnBinarySearchError)
2
+ class MSlinnBinarySearchError < StandardError
3
+ end
4
+ end
5
+
6
+ # Ruby's binary search is unsuitable because the value to be searched for changes the required ordering for String compares
7
+ class MSlinnBinarySearch
8
+ attr_reader :accessor_chain, :array # For testing only
9
+
10
+ def initialize(accessor_chain)
11
+ @array = SortedSet.new # [LruFile] Ordered highest to lowest
12
+ @accessor_chain = accessor_chain
13
+ end
14
+
15
+ # Convert the SortedSet to an Array
16
+ def enable_search
17
+ @array = @array.to_a
18
+ end
19
+
20
+ # A match is found when the Array[LruFile] has an href which starts with the given stem
21
+ # @param stem [String]
22
+ # @return first item from @array.url that matches, or nil if no match
23
+ def find(stem)
24
+ raise MSlinnBinarySearchError, 'Invalid find because stem to search for is nil.' if stem.nil?
25
+
26
+ index = find_index(stem)
27
+ return nil if index.nil?
28
+
29
+ @array[index]
30
+ end
31
+
32
+ # @param stem [String]
33
+ # @return index of first matching stem, or nil if @array is empty, or 0 if no stem specified
34
+ def find_index(stem)
35
+ return nil if @array.empty?
36
+ return 0 if stem.nil? || stem.empty?
37
+
38
+ mets = stem.reverse
39
+ return nil if @array[0].url[0...mets.size] > mets # TODO: use chain eval for item
40
+ return nil if @array[0].url[0] != mets[0]
41
+
42
+ _find_index(mets, 0, @array.length - 1)
43
+ end
44
+
45
+ # @param stem [String]
46
+ # @return [index] of matching values, or [] if @array is empty, or entire array if no stem specified
47
+ def find_indices(stem)
48
+ return [] if @array.empty?
49
+ return @array if stem.nil? || stem.empty?
50
+
51
+ first_index = _find_index(stem, 0, @array.length - 1)
52
+ last_index = first_index
53
+ last_index += 1 while @array[last_index].url.start_with? stem
54
+ [first_index..last_index]
55
+ end
56
+
57
+ # @param item [LruFile]
58
+ # @return [int] index of matching LruFile in @array, or nil if not found
59
+ def index_of(lru_file)
60
+ raise MSlinnBinarySearchError, 'Invalid index_of lru_file (nil).' if lru_file.nil?
61
+
62
+ find_index lru_file.url
63
+ end
64
+
65
+ # @return [LruFile] item at given index in @array
66
+ def item_at(index)
67
+ if index > @array.length - 1
68
+ raise MSlinnBinarySearchError,
69
+ "Invalid item_at index (#{index}) is greater than maximum stem (#{@array.length - 1})."
70
+ end
71
+ raise MSlinnBinarySearchError, "Invalid item_at index (#{index}) is less than zero." if index.negative?
72
+
73
+ @array[index]
74
+ end
75
+
76
+ # @param lru_file [LruFile]
77
+ def insert(lru_file)
78
+ raise MSlinnBinarySearchError, 'Invalid insert because new item is nil.' if lru_file.nil?
79
+ raise MSlinnBinarySearchError, "Invalid insert because new item has no chain (#{lru_file})" if lru_file.chain.nil?
80
+
81
+ @array.add lru_file
82
+ end
83
+
84
+ # TODO: Cache this method
85
+ # @param suffix [String] to use stem search on
86
+ # @return nil if @array is empty
87
+ # @return the first item in @array if suffix is nil or an empty string
88
+ def prefix_search(suffix)
89
+ return nil if @array.empty?
90
+ return @array[0] if suffix.empty? || suffix.nil?
91
+
92
+ low = search_index { |x| x.evaluate_with suffix }
93
+ return [] if low.nil?
94
+
95
+ high = low
96
+ high += 1 while high < @array.length &&
97
+ @array[high].evaluate_with(suffix)
98
+ @array[low..high]
99
+ end
100
+
101
+ # @param stem [String]
102
+ # @return [APage] matching APages, or [] if @array is empty, or entire array if no stem specified
103
+ def select_pages(stem)
104
+ first_index = find_index stem
105
+ return [] if first_index.nil?
106
+
107
+ last_index = first_index
108
+ while last_index < @array.length - 1
109
+ # LruFile.url is reversed, bug LruFile.page is not
110
+ break unless @array[last_index + 1].url.start_with?(stem.reverse)
111
+
112
+ last_index += 1
113
+ end
114
+ Range.new(first_index, last_index).map { |i| @array[i].page }
115
+ end
116
+
117
+ private
118
+
119
+ # A match is found when the Array[LruFile] has an href which starts with the given stem
120
+ # @param stem [String]
121
+ # @return [int] first index in @array that matches, or nil if no match
122
+ def _find_index(mets, min_index, max_index)
123
+ raise MSlinnBinarySearchError, "_find_index min_index(#{min_index})<0" if min_index.negative?
124
+ raise MSlinnBinarySearchError, "_find_index min_index(#{min_index})>max_index(#{max_index})" if min_index > max_index
125
+ raise MSlinnBinarySearchError, "_find_index max_index(#{max_index})>=@array.length(#{@array.length})" if max_index >= @array.length
126
+
127
+ return min_index if (min_index == max_index) && @array[min_index].url.start_with?(mets)
128
+
129
+ while min_index < max_index
130
+ mid_index = (min_index + max_index) / 2
131
+ mid_value = @array[mid_index].url[0...(mets.size)] # TODO: use chain eval for item
132
+
133
+ if mid_value == mets # back up until the first match is found
134
+ index = mid_index
135
+ loop do
136
+ return 0 if index.zero?
137
+
138
+ return index unless @array[index - 1].url.start_with?(mets)
139
+
140
+ index -= 1
141
+ end
142
+ elsif mid_value > mets
143
+ max_index = mid_index - 1
144
+ return _find_index(mets, min_index, max_index)
145
+ else
146
+ min_index = mid_index + 1
147
+ return _find_index(mets, min_index, max_index)
148
+ end
149
+ end
150
+ nil
151
+ end
152
+ end
@@ -0,0 +1,58 @@
1
+ require_relative '../util/mslinn_binary_search'
2
+
3
+ # Supports one chain at a time
4
+ module SendChain
5
+ # See https://stackoverflow.com/a/79333706/553865
6
+ # This method can be called directly if no methods in the chain require arguments
7
+ # Does not use any external state
8
+ def send_chain(chain)
9
+ Array(chain).inject(self) { |o, a| o.send(*a) }
10
+ end
11
+
12
+ # Saves @chain structure containing :placeholders for arguments to be supplied later
13
+ # Call when a different chain with :placeholders is desired
14
+ def new_chain(chain)
15
+ abort "new_chain error: chain must be an array ('#{chain}' was an #{chain.class.name})" \
16
+ unless chain.instance_of?(Array)
17
+ @chain = chain
18
+ end
19
+
20
+ # Call after new_chain, to evaluate @chain with values
21
+ def substitute_and_send_chain_with(values)
22
+ send_chain substitute_chain_with values
23
+ end
24
+
25
+ alias evaluate_with substitute_and_send_chain_with
26
+
27
+ # Call this method after calling new_chain to perform error checking and replace :placeholders with values.
28
+ # @chain is not modified.
29
+ # @return [Array] Modified chain
30
+ def substitute_chain_with(values)
31
+ values = [values] unless values.instance_of?(Array)
32
+
33
+ placeholder_count = @chain.flatten.count { |x| x == :placeholder }
34
+ if values.length != placeholder_count
35
+ abort "with_values error: number of values (#{values.length}) does not match the number of placeholders (#{placeholder_count})"
36
+ end
37
+
38
+ eval_chain @chain, values
39
+ end
40
+
41
+ private
42
+
43
+ # Replaces :placeholders with values
44
+ # Does not use any external state
45
+ # @return modified chain
46
+ def eval_chain(chain, values)
47
+ chain.map do |c|
48
+ case c
49
+ when :placeholder
50
+ values.shift
51
+ when Array
52
+ eval_chain c, values
53
+ else
54
+ c
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,4 +1,5 @@
1
1
  require 'spec_helper'
2
+ require_relative '../../lib/jekyll_all_collections'
2
3
 
3
4
  class APageStub
4
5
  attr_reader :date, :last_modified, :label
@@ -0,0 +1,50 @@
1
+ require 'spec_helper'
2
+
3
+ # Ruby's binary search is unsuitable because ordering requirements are not stable.
4
+ # the value to be searched for changes the required ordering
5
+
6
+ RSpec.describe(Array) do
7
+ before { skip('Never gonna give you up/Never gonna let you down') }
8
+
9
+ sorted_ints = [0, 4, 7, 10, 12]
10
+ sorted_strings = %w[aaa aab aac bbb bbc bbd ccc ccd cce].sort.reverse
11
+
12
+ it 'returns index of first int match' do
13
+ actual = sorted_ints.bsearch_index { |x| x >= 4 }
14
+ expect(actual).to eq(1)
15
+
16
+ actual = sorted_ints.bsearch_index { |x| x >= 6 }
17
+ expect(actual).to eq(2)
18
+
19
+ actual = sorted_ints.bsearch_index { |x| x >= -1 }
20
+ expect(actual).to eq(0)
21
+
22
+ actual = sorted_ints.bsearch_index { |x| x >= 100 }
23
+ expect(actual).to be_nil
24
+ end
25
+
26
+ # See https://stackoverflow.com/q/79333097/553865
27
+ it 'returns index of first string match' do
28
+ puts(sorted_strings.map { |x| x.start_with? 'a' })
29
+ index = sorted_strings.bsearch_index { |x| x.start_with? 'a' }
30
+ expect(sorted_strings[index]).to eq('aac')
31
+
32
+ index = sorted_strings.bsearch_index { |x| x.start_with? 'aa' }
33
+ expect(sorted_strings[index]).to eq('aac')
34
+
35
+ index = sorted_strings.bsearch_index { |x| x.start_with? 'aaa' }
36
+ expect(sorted_strings[index]).to eq('aaa')
37
+
38
+ index = sorted_strings.bsearch_index { |x| x.start_with? 'b' }
39
+ expect(sorted_strings[index]).to eq('bbd')
40
+
41
+ index = sorted_strings.bsearch_index { |x| x.start_with? 'bb' }
42
+ expect(sorted_strings[index]).to eq('bbd')
43
+
44
+ index = sorted_strings.bsearch_index { |x| x.start_with? 'bbc' }
45
+ expect(sorted_strings[index]).to eq('bbc')
46
+
47
+ index = sorted_strings.bsearch_index { |x| x.start_with? 'cc' }
48
+ expect(sorted_strings[index]).to eq('cce')
49
+ end
50
+ end