Module: Jekyll::Plugins::PaginateV3::Utils

Defined in:
lib/jekyll-paginate-v3/utils/core.rb,
lib/jekyll-paginate-v3/utils/items.rb,
lib/jekyll-paginate-v3/utils/paths.rb,
lib/jekyll-paginate-v3/utils/logger.rb,
lib/jekyll-paginate-v3/utils/formatting.rb,
lib/jekyll-paginate-v3/utils/nested_data.rb

Overview

Nested frontmatter lookup helpers with equivalent-key support.

Used by filtering, sorting, and index grouping for nested key reads.

Defined Under Namespace

Classes: Logger

Class Method Summary collapse

Class Method Details

.arrayify(value, split_commas: false, split_delimiter: nil) ⇒ Object

Converts a value into an array. Strings can be treated as delimiter-defined lists.



42
43
44
45
46
47
48
49
50
51
52
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 42

def self.arrayify(value, split_commas: false, split_delimiter: nil)
	delimiter = split_delimiter
	delimiter = ',' if delimiter.nil? && split_commas

	Jekyll::Plugins::PaginateV3::Support::StringArray.new(delimiter: delimiter || ',').interpret(
		value,
		split: delimiter.nil? ? false : 0,
		flatten: true,
		delimiter: delimiter.nil? ? Jekyll::Plugins::PaginateV3::Support::StringArray::UNSET : delimiter
	)
end

.build_equivalent_lookup(raw_equivalents, split_delimiter: ',') ⇒ Object

Builds lookup table used for equivalent key resolution. Each entry is keyed by full key-path string (for example product.tag), which allows equivalent mappings to be scoped to one nested level only.



21
22
23
# File 'lib/jekyll-paginate-v3/utils/nested_data.rb', line 21

def self.build_equivalent_lookup(raw_equivalents, split_delimiter: ',')
	Jekyll::Plugins::PaginateV3::Support::FrontmatterPath.build_equivalent_lookup(raw_equivalents, split_delimiter: split_delimiter)
end

.build_pagination_windows(total_items, per_page) ⇒ Object

Builds page windows for a total item count and per-page definition.

Each returned window has:

  • num: page number
  • page_size: configured capacity for this page
  • count: actual number of items on this page
  • offset_start / offset_end: zero-based slice bounds
  • start / end: 1-based item positions (or nil when empty)


52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/jekyll-paginate-v3/utils/items.rb', line 52

def self.build_pagination_windows(total_items, per_page)
	safe_total_items = [total_items.to_i, 0].max

	if safe_total_items.zero?
		first_page_size = page_size_for_number(per_page, 1)
		return [
			{
				'num' => 1,
				'page_size' => first_page_size,
				'count' => 0,
				'offset_start' => 0,
				'offset_end' => 0,
				'start' => nil,
				'end' => nil
			}
		]
	end

	windows = []
	page_number = 1
	offset = 0

	while offset < safe_total_items
		page_size = page_size_for_number(per_page, page_number)
		count = [page_size, safe_total_items - offset].min
		start_item_index = offset + 1
		end_item_index = offset + count

		windows << {
			'num' => page_number,
			'page_size' => page_size,
			'count' => count,
			'offset_start' => offset,
			'offset_end' => offset + count,
			'start' => start_item_index,
			'end' => end_item_index
		}

		offset += count
		page_number += 1
	end

	windows
end

.build_synthetic_filename(extension:, signature:, source_path: nil, source_stem: nil, role:, page_number: nil) ⇒ Object

Builds one readable synthetic filename from source identity plus a deterministic safety suffix.



75
76
77
78
79
80
81
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 75

def self.build_synthetic_filename(extension:, signature:, source_path: nil, source_stem: nil, role:, page_number: nil)
	stem = derive_synthetic_source_stem(source_path: source_path, source_stem: source_stem, fallback: role)
	filename_segments = [stem, role.to_s.strip]
	filename_segments << page_number.to_i.to_s unless page_number.nil?
	filename_segments << Digest::MD5.hexdigest(canonical_signature(signature).inspect)
	"#{filename_segments.reject(&:empty?).join('-')}#{ensure_leading_dot(extension)}"
end

.build_synthetic_source_path(site:, extension:, signature:, collection: nil, source_path: nil, source_stem: nil, role:, page_number: nil) ⇒ Object

Builds one deterministic synthetic source path for in-memory pages and documents. Filenames remain readable for debugging while a hash suffix preserves practical uniqueness across templates and variants.



58
59
60
61
62
63
64
65
66
67
68
69
70
71
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 58

def self.build_synthetic_source_path(site:, extension:, signature:, collection: nil, source_path: nil, source_stem: nil, role:, page_number: nil)
	directory = collection.nil? ? site.source : File.join(site.source, collection.relative_directory)
	File.join(
		directory,
		build_synthetic_filename(
			extension: extension,
			signature: signature,
			source_path: source_path,
			source_stem: source_stem,
			role: role,
			page_number: page_number
		)
	)
end

.calculate_number_of_pages(items, per_page) ⇒ Object

Calculates page count for a list and one per-page definition.

per_page can be either:

  • Integer: fixed page size.
  • Array: nth entry is used for page n, with the final entry reused.


17
18
19
# File 'lib/jekyll-paginate-v3/utils/items.rb', line 17

def self.calculate_number_of_pages(items, per_page)
	build_pagination_windows(items.size, per_page).length
end

.canonical_signature(value) ⇒ Object

Canonicalises nested signature data so hashes with identical meaning yield the same suffix regardless of insertion order.



135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 135

def self.canonical_signature(value)
	case value
	when Hash
		value.each_with_object({}) do |(key, nested_value), canonical|
			canonical[key.to_s] = canonical_signature(nested_value)
		end.sort_by { |key, _| key }.to_h
	when Array
		value.map { |entry| canonical_signature(entry) }
	else
		value
	end
end

.comma_delimited_array(value) ⇒ Object

Backwards-compatible alias for comma-delimited list parsing.



69
70
71
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 69

def self.comma_delimited_array(value)
	delimited_array(value, delimiter: ',').map { |entry| entry.to_s.strip }.reject(&:empty?)
end

.deep_copy(value) ⇒ Object

Deep copy helper for plain Ruby hashes/arrays used in config merging.



13
14
15
16
17
18
19
20
21
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 13

def self.deep_copy(value)
	if value.is_a?(Hash)
		value.each_with_object({}) { |(key, child), copy| copy[key] = deep_copy(child) }
	elsif value.is_a?(Array)
		value.map { |child| deep_copy(child) }
	else
		value
	end
end

.delimited_array(value, delimiter: ',') ⇒ Object

Converts scalars/arrays into a flat array and applies delimited-string expansion for all string entries.



37
38
39
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 37

def self.delimited_array(value, delimiter: ',')
	Jekyll::Plugins::PaginateV3::Support::StringArray.new(delimiter: delimiter).interpret(value, split: -1, flatten: true)
end

.derive_synthetic_source_stem(source_path: nil, source_stem: nil, fallback: 'generated') ⇒ Object

Derives one human-readable synthetic stem from an original source path or fallback label.



85
86
87
88
89
90
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 85

def self.derive_synthetic_source_stem(source_path: nil, source_stem: nil, fallback: 'generated')
	candidate = extract_source_filename_stem(source_path)
	candidate = source_stem.to_s if candidate.empty?
	candidate = fallback.to_s if candidate.to_s.strip.empty?
	sanitise_filename_component(candidate, fallback: fallback)
end

.derive_synthetic_source_stem_from_frontmatter(frontmatter, fallback: 'generated') ⇒ Object

Derives one readable stem from frontmatter when no real source path exists, preferring permalink and then title.



94
95
96
97
98
99
100
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 94

def self.derive_synthetic_source_stem_from_frontmatter(frontmatter, fallback: 'generated')
	frontmatter_hash = safe_hash(frontmatter)
	permalink_stem = extract_permalink_stem(frontmatter_hash['permalink'])
	return derive_synthetic_source_stem(source_stem: permalink_stem, fallback: fallback) unless permalink_stem.empty?

	derive_synthetic_source_stem(source_stem: frontmatter_hash['title'], fallback: fallback)
end

.ensure_full_path(path, default_index_name, default_extension) ⇒ Object

Normalises a full path by appending a default filename and extension when needed.



41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 41

def self.ensure_full_path(path, default_index_name, default_extension)
	url = path.to_s
	extension = ensure_leading_dot(default_extension)
	index_name = default_index_name.to_s

	if url.end_with?('/')
		return "#{url}#{index_name}#{extension}"
	end

	return "#{url}#{extension}" if File.extname(url).empty?

	url
end

.ensure_leading_dot(extension) ⇒ Object

Ensures a filename extension has a leading dot.



33
34
35
36
37
38
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 33

def self.ensure_leading_dot(extension)
	string_extension = extension.to_s
	return '' if string_extension.empty?

	string_extension.start_with?('.') ? string_extension : ".#{string_extension}"
end

.ensure_leading_slash(path) ⇒ Object

Ensures a path-like string has a leading slash.



21
22
23
24
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 21

def self.ensure_leading_slash(path)
	string_path = path.to_s
	string_path.start_with?('/') ? string_path : "/#{string_path}"
end

.ensure_trailing_slash(path) ⇒ Object

Ensures a path-like string has a trailing slash.



27
28
29
30
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 27

def self.ensure_trailing_slash(path)
	string_path = path.to_s
	string_path.end_with?('/') ? string_path : "#{string_path}/"
end

Extracts one final path segment from a permalink-like value.



111
112
113
114
115
116
117
118
119
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 111

def self.extract_permalink_stem(permalink)
	path = permalink.to_s.strip
	return '' if path.empty?

	segments = path.split('/').reject(&:empty?)
	return '' if segments.empty?

	File.basename(segments.last, File.extname(segments.last))
end

.extract_source_filename_stem(source_path) ⇒ Object

Extracts one source-style stem from a real path-like value.



103
104
105
106
107
108
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 103

def self.extract_source_filename_stem(source_path)
	path = source_path.to_s
	return '' if path.strip.empty?

	File.basename(path, File.extname(path))
end

.fetch_nested_values(data, key_path, separator, equivalent_lookup) ⇒ Object

Retrieves all possible values from a nested key path.



39
40
41
42
43
44
45
# File 'lib/jekyll-paginate-v3/utils/nested_data.rb', line 39

def self.fetch_nested_values(data, key_path, separator, equivalent_lookup)
	path_reader = Jekyll::Plugins::PaginateV3::Support::FrontmatterPath.new(
		separator: separator,
		equivalent_lookup: equivalent_lookup
	)
	scalar_values(path_reader.traverse(data, key_path))
end

.format_page_number(pattern, current_page, max_pages = nil) ⇒ Object

Replaces :num and optionally :max placeholders.



14
15
16
17
18
# File 'lib/jekyll-paginate-v3/utils/formatting.rb', line 14

def self.format_page_number(pattern, current_page, max_pages = nil)
	output = pattern.to_s.sub(':num', current_page.to_i.to_s)
	output = output.sub(':max', max_pages.to_i.to_s) unless max_pages.nil?
	output
end

.format_page_title(pattern, title, current_page = nil, max_pages = nil) ⇒ Object

Replaces :title and numeric placeholders in title patterns.



21
22
23
# File 'lib/jekyll-paginate-v3/utils/formatting.rb', line 21

def self.format_page_title(pattern, title, current_page = nil, max_pages = nil)
	format_page_number(pattern.to_s.sub(':title', title.to_s), current_page, max_pages)
end

.generated_index?(item) ⇒ Boolean

Returns true when the object appears to be a generated index page.

Returns:

  • (Boolean)


98
99
100
101
102
103
104
105
# File 'lib/jekyll-paginate-v3/utils/items.rb', line 98

def self.generated_index?(item)
	return false unless item.respond_to?(:data)
	return false unless item.data.is_a?(Hash)

	return true if item.data.dig('pagination', 'generated') && item.data.dig('pagination', 'index')

	%w[jekyll-paginate-v2 jekyll-paginate-v3].include?(item.data['autogen'])
end

.item_collection_label(item) ⇒ Object

Collection label helper that treats pages as nil collection.



133
134
135
136
137
138
# File 'lib/jekyll-paginate-v3/utils/items.rb', line 133

def self.item_collection_label(item)
	return nil unless item.respond_to?(:collection)
	return nil if item.collection.nil?

	item.collection.respond_to?(:label) ? item.collection.label.to_s : nil
end

.merge_generated_template_pagination(generated_pagination, layout_pagination, compatibility_mode) ⇒ Object

Merges generated-template pagination config with layout pagination.

Default behaviour matches normal Jekyll precedence semantics: generated template config overrides layout defaults.

In v2 compatibility mode we retain legacy override order, but strip layout enabled so generated templates cannot be disabled by layout frontmatter.



87
88
89
90
91
92
93
94
95
96
97
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 87

def self.merge_generated_template_pagination(generated_pagination, layout_pagination, compatibility_mode)
	generated_hash = safe_hash(generated_pagination)
	layout_hash = safe_hash(layout_pagination)

	if compatibility_mode == 'v2'
		layout_hash.delete('enabled')
		return Jekyll::Utils.deep_merge_hashes(generated_hash, layout_hash)
	end

	Jekyll::Utils.deep_merge_hashes(layout_hash, generated_hash)
end

.normalise_layouts(config, split_delimiter: ',') ⇒ Object

Expands layout + layouts config into a unique array of layout names.



74
75
76
77
78
79
80
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 74

def self.normalise_layouts(config, split_delimiter: ',')
	source = safe_hash(config)
	layouts = []
	layouts.concat(arrayify(source['layouts'], split_delimiter: split_delimiter)) if source.key?('layouts')
	layouts.concat(arrayify(source['layout'], split_delimiter: split_delimiter)) if source.key?('layout')
	layouts.map { |entry| entry.to_s.strip }.reject(&:empty?).uniq
end

.normalise_per_page_pattern(per_page) ⇒ Object

Normalises a per-page definition into an integer array.

Invalid or non-positive entries are coerced to 1 so pagination remains valid and cannot produce zero-sized pages.



25
26
27
28
29
30
# File 'lib/jekyll-paginate-v3/utils/items.rb', line 25

def self.normalise_per_page_pattern(per_page)
	raw_entries = per_page.is_a?(Array) ? per_page.flatten.compact : [per_page]
	pattern = raw_entries.map { |entry| [entry.to_i, 1].max }

	pattern.empty? ? [1] : pattern
end

.normalise_split_delimiter(raw_delimiter, default_delimiter = ',') ⇒ Object

Normalises a configurable delimiter. Returns default_delimiter when the input is blank or unsupported.

false disables delimited splitting globally.



27
28
29
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 27

def self.normalise_split_delimiter(raw_delimiter, default_delimiter = ',')
	Jekyll::Plugins::PaginateV3::Support::StringArray.normalise_delimiter(raw_delimiter, default_delimiter)
end

.page_size_for_number(per_page, page_number) ⇒ Object

Resolves configured page size for one 1-based page number.

When the page number exceeds the per-page pattern length, the final pattern entry is reused indefinitely.



36
37
38
39
40
41
42
# File 'lib/jekyll-paginate-v3/utils/items.rb', line 36

def self.page_size_for_number(per_page, page_number)
	pattern = normalise_per_page_pattern(per_page)
	index = [page_number.to_i - 1, pattern.length - 1].min
	index = 0 if index.negative?

	pattern[index]
end

.pagination_template?(item) ⇒ Boolean

Returns true when the object looks like a pagination template.

Returns:

  • (Boolean)


108
109
110
111
112
113
114
115
116
# File 'lib/jekyll-paginate-v3/utils/items.rb', line 108

def self.pagination_template?(item)
	return false unless item.respond_to?(:data)
	return false unless item.data.is_a?(Hash)

	pagination = safe_hash(item.data['pagination'])
	return false if pagination.empty?

	pagination['template'] || pagination['enabled']
end

.read_hash(hash, key) ⇒ Object

Reads a value from hash by either string or symbol key.



34
35
36
# File 'lib/jekyll-paginate-v3/utils/nested_data.rb', line 34

def self.read_hash(hash, key)
	Jekyll::Plugins::PaginateV3::Support::FrontmatterPath.read_hash(hash, key)
end

.relative_item_path(item) ⇒ Object

Safe relative path for pages and documents.



119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/jekyll-paginate-v3/utils/items.rb', line 119

def self.relative_item_path(item)
	if item.respond_to?(:cleaned_relative_path)
		ext = item.respond_to?(:extname) ? item.extname.to_s : ''
		remove_leading_slash("#{item.cleaned_relative_path}#{ext}")
	elsif item.respond_to?(:relative_path)
		remove_leading_slash(item.relative_path.to_s)
	elsif item.respond_to?(:path)
		remove_leading_slash(item.path.to_s)
	else
		''
	end
end

.remove_leading_slash(path) ⇒ Object

Removes one leading slash from a path-like string.



15
16
17
18
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 15

def self.remove_leading_slash(path)
	string_path = path.to_s
	string_path.start_with?('/') ? string_path[1..-1] : string_path
end

.replace_tokens(template, token_map) ⇒ Object

Replaces placeholders in a string where keys are in token_map.

Replacement is done in one pass using a longest-key-first matcher so overlapping placeholders stay deterministic, for example :foob always wins over :foo in :foobar.



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/jekyll-paginate-v3/utils/formatting.rb', line 30

def self.replace_tokens(template, token_map)
	output = template.to_s
	token_source = token_map.is_a?(Hash) ? token_map : {}
	normalised_token_map = token_source.each_with_object({}) do |(raw_key, value), memo|
		key = raw_key.to_s
		next if key.empty?

		memo[key] = value.to_s
	end
	return output if normalised_token_map.empty?

	sorted_keys = normalised_token_map.keys.sort_by { |key| [-key.length, key] }
	token_pattern = /:(#{sorted_keys.map { |key| Regexp.escape(key) }.join('|')})/

	output.gsub(token_pattern) do
		normalised_token_map[Regexp.last_match(1)]
	end
end

.resolve_hash_key(hash, requested_key_path, equivalent_lookup, separator: '.') ⇒ Object

Resolves the effective hash key for one requested nested key path. Equivalent lookups are performed using the full requested path; only terminal segments from that matched group are candidates for hash access at this level.



29
30
31
# File 'lib/jekyll-paginate-v3/utils/nested_data.rb', line 29

def self.resolve_hash_key(hash, requested_key_path, equivalent_lookup, separator: '.')
	Jekyll::Plugins::PaginateV3::Support::FrontmatterPath.resolve_hash_key(hash, requested_key_path, equivalent_lookup, separator: separator)
end

.safe_hash(value) ⇒ Object

Returns a hash from any input object, or an empty hash for unsupported values.



64
65
66
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 64

def self.safe_hash(value)
	value.is_a?(Hash) ? stringify_keys(value) : {}
end

.sanitise_filename_component(value, fallback: 'generated') ⇒ Object

Sanitises one filename component while preserving source readability where the original stem is already filesystem-safe.



123
124
125
126
127
128
129
130
131
# File 'lib/jekyll-paginate-v3/utils/paths.rb', line 123

def self.sanitise_filename_component(value, fallback: 'generated')
	component = value.to_s.strip
	component = component.gsub(%r{[\\/]}, '-')
	component = component.gsub(/[<>:"|?*\x00-\x1f]/, '-')
	component = component.gsub(/^-+/, '')
	component = component.gsub(/[. ]+$/, '')
	component = fallback.to_s if component.empty?
	component
end

.scalar_values(value) ⇒ Object

Converts a mixed scalar/array value into a flat array of scalar values.



48
49
50
51
52
53
54
55
56
# File 'lib/jekyll-paginate-v3/utils/nested_data.rb', line 48

def self.scalar_values(value)
	if value.is_a?(Array)
		value.flatten.compact
	elsif value.nil?
		[]
	else
		[value]
	end
end

.split_delimited_string(value, delimiter) ⇒ Object

Splits one string using the configured delimiter, trims entries, and rejects blank strings.



32
33
34
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 32

def self.split_delimited_string(value, delimiter)
	Jekyll::Plugins::PaginateV3::Support::StringArray.new(delimiter: delimiter).interpret(value, split: 0, flatten: true)
end

.split_nested_key(key, separator) ⇒ Object

Splits a nested key according to configured separator.



13
14
15
# File 'lib/jekyll-paginate-v3/utils/nested_data.rb', line 13

def self.split_nested_key(key, separator)
	Jekyll::Plugins::PaginateV3::Support::FrontmatterPath.split_path(key, separator)
end

.stringify_keys(value) ⇒ Object

Normalises hash keys recursively to strings.



55
56
57
58
59
60
61
# File 'lib/jekyll-paginate-v3/utils/core.rb', line 55

def self.stringify_keys(value)
	return value unless value.is_a?(Hash)

	value.each_with_object({}) do |(key, child), copy|
		copy[key.to_s] = child.is_a?(Hash) ? stringify_keys(child) : child
	end
end