jekyll-paginate-v3 0.1.0.alpha.2 → 0.1.0.alpha.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 036fbbfba18f61ae73a069e4330fce833a1cb7549704c9bf396961ddd262569f
4
- data.tar.gz: 061d674f1bfd29de53bb79bc5a64feb8076a80af38f59be877f3cc77443173ff
3
+ metadata.gz: 4167a9c3e76eb08e45275f31be52227e30a6b6d453f1bceef0c901097d4250f7
4
+ data.tar.gz: e8e96586a43ff094865264446fc5389555438c74e87dbeb38f5c0c818b2ed47e
5
5
  SHA512:
6
- metadata.gz: e6561b1d072672dabdbace597b3b7d310a3f357f7d9b05a1f6815d7b29e634029faf7828ca8105edf6cc41fd1cab276e215de8343358c53e42b482e005f64235
7
- data.tar.gz: 259bd8d452c747203d316a977de290141b11bc1061a39386e0bac81733f1ace2b08ef0b9c1d8092212e232544bb6f8f758e2e49327b6779f54116e555213a7d4
6
+ metadata.gz: 457fc67c2920f51aac38003b395720365df59f5786a9fb71f1d34c6d68d5a279abb32f10608b3d4c2c66b93934f8108e26175165795bb4a253c43898eb89d12d
7
+ data.tar.gz: a44bb5a0591d319502f0f574d5fa0493a4636477e7a8301ba9a9c5fc712b74f1564b169617b15c950512f52f29d2a23d3b6207c86b8d09cddbd182f81e4fdca3
@@ -27,6 +27,20 @@ module Config
27
27
  'items' => 'items'
28
28
  }.freeze
29
29
 
30
+ # Internal collection-mode identifiers. These never appear in user
31
+ # configuration, which lets a renamed keyword release its original word for
32
+ # use as a real Jekyll collection label.
33
+ COLLECTION_TARGET_PAGES = '__paginate_v3_collection_target_pages__'.freeze
34
+ COLLECTION_TARGET_SELF = '__paginate_v3_collection_target_self__'.freeze
35
+ COLLECTION_TARGET_SHADOW = '__paginate_v3_collection_target_shadow__'.freeze
36
+ COLLECTION_TARGET_CLONE = '__paginate_v3_collection_target_clone__'.freeze
37
+ COLLECTION_TARGET_BY_KEY = {
38
+ 'pages' => COLLECTION_TARGET_PAGES,
39
+ 'self' => COLLECTION_TARGET_SELF,
40
+ 'shadow' => COLLECTION_TARGET_SHADOW,
41
+ 'clone' => COLLECTION_TARGET_CLONE
42
+ }.freeze
43
+
30
44
  DEFAULTS = {
31
45
  'enabled' => true,
32
46
  'compatibility' => nil,
@@ -41,7 +55,7 @@ module Config
41
55
  ['category', 'categories']
42
56
  ],
43
57
  'items' => nil,
44
- 'collection' => ['self', 'shadow'],
58
+ 'collection' => [COLLECTION_TARGET_SELF, COLLECTION_TARGET_SHADOW],
45
59
  'filters' => nil,
46
60
  'sort' => 'date desc',
47
61
  'per_page' => 10,
@@ -58,7 +72,7 @@ module Config
58
72
  'lowercase' => true
59
73
  },
60
74
  'templates' => {
61
- 'location' => 'pages',
75
+ 'location' => nil,
62
76
  'generate' => []
63
77
  }
64
78
  }.freeze
@@ -95,7 +109,6 @@ module Config
95
109
  },
96
110
  'items' => 'posts',
97
111
  'templates' => {
98
- 'location' => 'pages',
99
112
  'generate' => []
100
113
  }
101
114
  }
@@ -72,26 +72,28 @@ class Normaliser
72
72
  autopages = Utils.safe_hash(raw_autopages)
73
73
  return if autopages.empty? || autopages['enabled'] == false
74
74
 
75
+ # Use the effective search keyword because compatibility profiles may rename `all` and release the original word for use as a collection label.
76
+ all_items_keyword = config.dig('keywords', 'all')
75
77
  migrated = []
76
78
 
77
79
  migrated.concat(migrate_v2_autopage_group(
78
80
  raw_group: autopages['tags'],
79
81
  index_key: 'tag',
80
- items: 'all',
82
+ items: all_items_keyword,
81
83
  defaults: V2_AUTOPAGE_DEFAULTS['tags'],
82
84
  split_delimiter: config.dig('syntax', 'split')
83
85
  ))
84
86
  migrated.concat(migrate_v2_autopage_group(
85
87
  raw_group: autopages['categories'],
86
88
  index_key: 'category',
87
- items: 'all',
89
+ items: all_items_keyword,
88
90
  defaults: V2_AUTOPAGE_DEFAULTS['categories'],
89
91
  split_delimiter: config.dig('syntax', 'split')
90
92
  ))
91
93
  migrated.concat(migrate_v2_autopage_group(
92
94
  raw_group: autopages['collections'],
93
95
  index_key: 'collection',
94
- items: 'all',
96
+ items: all_items_keyword,
95
97
  defaults: V2_AUTOPAGE_DEFAULTS['collections'],
96
98
  split_delimiter: config.dig('syntax', 'split')
97
99
  ))
@@ -58,7 +58,8 @@ class Normaliser
58
58
  config['equivalents'] = normalise_equivalents(config['equivalents'], split_delimiter)
59
59
  config['templates'] = normalise_templates(
60
60
  config['templates'],
61
- split_delimiter: split_delimiter
61
+ split_delimiter: split_delimiter,
62
+ keywords: config['keywords']
62
63
  )
63
64
 
64
65
  normalised_template_defaults = normalise_template_defaults(
@@ -188,13 +189,15 @@ class Normaliser
188
189
  # Connects to: the surrounding pagination flow in this file.
189
190
  # Params: `raw_templates`, `split_delimiter`.
190
191
  # Returns: a value consumed by the next pipeline step.
191
- def normalise_templates(raw_templates, split_delimiter:)
192
+ def normalise_templates(raw_templates, split_delimiter:, keywords:)
192
193
  defaults = Utils.deep_copy(DEFAULTS['templates'])
193
194
  source_hash = Utils.safe_hash(raw_templates)
194
195
  source = defaults.merge(source_hash)
195
196
  source.delete('defaults')
196
197
 
197
- source['location'] = defaults['location'] if source['location'].nil? || source['location'].to_s.strip.empty?
198
+ # Default to the currently configured site-pages keyword so changing it
199
+ # does not make the default location collide with a collection label.
200
+ source['location'] = keywords['pages'] if source['location'].nil? || source['location'].to_s.strip.empty?
198
201
  source['generate'] = if source['generate'].is_a?(Array)
199
202
  source['generate'].map { |entry| Utils.safe_hash(entry) }
200
203
  elsif source['generate'].is_a?(Hash)
@@ -344,7 +347,7 @@ class Normaliser
344
347
  def normalise_collection_targets(raw_collection, split_delimiter:, keywords:)
345
348
  default_targets = Utils.deep_copy(DEFAULTS['collection'])
346
349
  entries = Utils.delimited_array(raw_collection, delimiter: split_delimiter)
347
- entries = Utils.deep_copy(default_targets) if entries.empty?
350
+ return default_targets if entries.empty?
348
351
 
349
352
  normalised = entries.map do |entry|
350
353
  normalise_collection_target_entry(entry, keywords)
@@ -363,21 +366,12 @@ class Normaliser
363
366
  entry = raw_entry.to_s.strip
364
367
  return '' if entry.empty?
365
368
 
366
- keyword_map = {
367
- 'pages' => keywords['pages'],
368
- 'self' => keywords['self'],
369
- 'shadow' => keywords['shadow'],
370
- 'clone' => keywords['clone']
371
- }
372
-
373
- keyword_map.each do |canonical, keyword|
369
+ COLLECTION_TARGET_BY_KEY.each do |key, internal_target|
370
+ keyword = keywords[key]
374
371
  next if keyword.to_s.empty?
375
- return canonical if entry == keyword
372
+ return internal_target if entry == keyword
376
373
  end
377
374
 
378
- canonical = entry.downcase
379
- return canonical if %w[pages self shadow clone].include?(canonical)
380
-
381
375
  entry
382
376
  end
383
377
 
@@ -149,7 +149,7 @@ class PaginationGenerator < Jekyll::Generator
149
149
  # Converts a generated-template destination location into readable text.
150
150
  def generated_template_location_label(collection_targets)
151
151
  targets = Utils.arrayify(collection_targets).map(&:to_s).map(&:strip).reject(&:empty?)
152
- targets = ['pages'] if targets.empty?
152
+ targets = [Config::COLLECTION_TARGET_PAGES] if targets.empty?
153
153
  return target_label(targets.first) if targets.length == 1
154
154
 
155
155
  "page1=#{target_label(targets.first)} page2+=#{target_label(targets[1])}"
@@ -157,7 +157,7 @@ class PaginationGenerator < Jekyll::Generator
157
157
 
158
158
  # Converts one collection target token to a readable log label.
159
159
  def target_label(target)
160
- return 'pages (site root)' if target == 'pages'
160
+ return 'pages (site root)' if target == Config::COLLECTION_TARGET_PAGES
161
161
 
162
162
  "collection '#{target}'"
163
163
  end
@@ -28,7 +28,7 @@ class Model
28
28
  @nested_separator = site_config.dig('syntax', 'separator')
29
29
  @split_delimiter = site_config.dig('syntax', 'split')
30
30
  @equivalents = site_config['equivalents']
31
- @item_keyword = site_config.dig('keywords', 'items') || 'items'
31
+ @item_keyword = site_config.dig('keywords', 'items') || Config::KEYWORD_DEFAULTS.fetch('items')
32
32
  @generated_index_sets = {}
33
33
  @clone_collection_cache = {}
34
34
  @template_search_reports = []
@@ -490,13 +490,13 @@ class Model
490
490
  [Utils.item_collection_label(item).to_s, Utils.relative_item_path(item)]
491
491
  end
492
492
 
493
- # Resolves one parsed search entry (`pages`, collection label, etc).
493
+ # Resolves one parsed search entry (a private source type or collection label).
494
494
  def resolve_entry(entry)
495
495
  type = entry['type']
496
496
  paths = entry['paths']
497
497
  source_items = source_items_for_entry_type(type)
498
498
  if source_items.nil?
499
- log("Search entry type='#{type}' did not match pages/all/everything or a known collection; resolved 0 items.", 'warn')
499
+ log("Search entry type='#{type}' did not match a configured search keyword or known collection; resolved 0 items.", 'warn')
500
500
  return []
501
501
  end
502
502
 
@@ -512,11 +512,11 @@ class Model
512
512
  # Resolves source items for one parsed search entry type.
513
513
  def source_items_for_entry_type(type)
514
514
  case type
515
- when 'pages'
515
+ when Query::Parser::SEARCH_TYPE_PAGES
516
516
  @item_resolution_pages || @site.pages
517
- when 'all'
517
+ when Query::Parser::SEARCH_TYPE_ALL
518
518
  all_collection_documents
519
- when 'everything'
519
+ when Query::Parser::SEARCH_TYPE_EVERYTHING
520
520
  (@item_resolution_pages || @site.pages) + all_collection_documents
521
521
  else
522
522
  return @item_resolution_documents_by_collection[type] if @item_resolution_documents_by_collection&.key?(type)
@@ -253,9 +253,9 @@ class Model
253
253
  target_collection = collection_target_for_page(template, target_mode)
254
254
 
255
255
  case target_mode
256
- when 'pages'
256
+ when Config::COLLECTION_TARGET_PAGES
257
257
  Pages::Page.new(template, current_page, total_pages, index_file)
258
- when 'shadow'
258
+ when Config::COLLECTION_TARGET_SHADOW
259
259
  Pages::ShadowPage.new(
260
260
  template,
261
261
  current_page,
@@ -281,7 +281,7 @@ class Model
281
281
  # Resolves one normalised collection mode for a generated page number.
282
282
  def collection_target_mode_for_page(template, config, current_page)
283
283
  targets = Utils.arrayify(config['collection']).map(&:to_s).map(&:strip).reject(&:empty?)
284
- targets = ['self', 'shadow'] if targets.empty?
284
+ targets = [Config::COLLECTION_TARGET_SELF, Config::COLLECTION_TARGET_SHADOW] if targets.empty?
285
285
  target = current_page == 1 ? targets.first : targets.last
286
286
 
287
287
  normalise_collection_target_for_template(template, target)
@@ -290,11 +290,11 @@ class Model
290
290
  # Resolves one collection object for collection-targeted modes.
291
291
  def collection_target_for_page(template, target_mode)
292
292
  case target_mode
293
- when 'self'
293
+ when Config::COLLECTION_TARGET_SELF
294
294
  return template.collection if collection_template?(template)
295
- when 'clone'
295
+ when Config::COLLECTION_TARGET_CLONE
296
296
  return clone_collection_for(template.collection) if collection_template?(template)
297
- when 'pages', 'shadow'
297
+ when Config::COLLECTION_TARGET_PAGES, Config::COLLECTION_TARGET_SHADOW
298
298
  return nil
299
299
  end
300
300
 
@@ -304,13 +304,13 @@ class Model
304
304
  # Converts abstract target modes to executable runtime modes.
305
305
  def normalise_collection_target_for_template(template, target_mode)
306
306
  value = target_mode.to_s.strip
307
- value = 'pages' if value.empty?
308
- return value unless %w[self shadow clone].include?(value)
309
- return 'pages' unless collection_template?(template)
310
- return value if value == 'self'
311
- return value if value == 'clone'
307
+ value = Config::COLLECTION_TARGET_PAGES if value.empty?
308
+ return value unless [Config::COLLECTION_TARGET_SELF, Config::COLLECTION_TARGET_SHADOW, Config::COLLECTION_TARGET_CLONE].include?(value)
309
+ return Config::COLLECTION_TARGET_PAGES unless collection_template?(template)
310
+ return value if value == Config::COLLECTION_TARGET_SELF
311
+ return value if value == Config::COLLECTION_TARGET_CLONE
312
312
 
313
- 'shadow'
313
+ Config::COLLECTION_TARGET_SHADOW
314
314
  end
315
315
 
316
316
  # Returns true when a template is a collection document.
@@ -365,7 +365,8 @@ class Model
365
365
  @site.config['defaults'] << clone_entry
366
366
  end
367
367
 
368
- @site.frontmatter_defaults.reset if @site.respond_to?(:frontmatter_defaults)
368
+ frontmatter_defaults = @site.frontmatter_defaults if @site.respond_to?(:frontmatter_defaults)
369
+ frontmatter_defaults.reset if frontmatter_defaults.respond_to?(:reset)
369
370
  end
370
371
 
371
372
  # Attaches a compact neighbourhood of page links around each generated
@@ -437,7 +438,7 @@ class Model
437
438
  @generated_index_sets[set_id] ||= []
438
439
  @generated_index_sets[set_id] << {
439
440
  'pages' => generated_pages,
440
- 'count' => generated_pages.first.pager.total_items,
441
+ 'count' => generated_pages.first.pager.total_item_count,
441
442
  'start' => metadata['start'],
442
443
  'end' => metadata['end'],
443
444
  'order' => metadata['order'].to_i,
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'digest'
4
-
5
3
  module Jekyll
6
4
  module Plugins
7
5
  module PaginateV3
@@ -22,7 +20,8 @@ class Document < Jekyll::Document
22
20
  def initialize(template_item, current_page, total_pages, _index_filename, collection:)
23
21
  target_collection = collection
24
22
  source_path = template_path(template_item)
25
- virtual_path = build_virtual_path(template_item.site, target_collection, source_path, current_page)
23
+ extension = template_extname(template_item)
24
+ virtual_path = build_virtual_path(template_item, target_collection, source_path, current_page, extension)
26
25
 
27
26
  initialise_document(template_item.site, target_collection, virtual_path)
28
27
 
@@ -32,7 +31,7 @@ class Document < Jekyll::Document
32
31
  'curr_page' => current_page,
33
32
  'total_pages' => total_pages
34
33
  }
35
- @extname = template_extname(template_item)
34
+ @extname = extension
36
35
  self.data['path'] = source_path if current_page == 1 && !source_path.empty?
37
36
 
38
37
  trigger_hooks(:post_init)
@@ -41,10 +40,22 @@ class Document < Jekyll::Document
41
40
  private
42
41
 
43
42
  # Builds a deterministic synthetic source path inside destination collection.
44
- def build_virtual_path(site, collection, source_path, current_page)
45
- signature = [source_path, collection.label, current_page].join(':')
46
- filename = "_paginate_v3_#{Digest::MD5.hexdigest(signature)}.md"
47
- File.join(site.source, collection.relative_directory, filename)
43
+ def build_virtual_path(template_item, collection, source_path, current_page, extension)
44
+ Utils.build_synthetic_source_path(
45
+ site: template_item.site,
46
+ collection: collection,
47
+ extension: extension,
48
+ source_path: source_path,
49
+ role: 'page',
50
+ page_number: current_page,
51
+ signature: {
52
+ 'source_path' => source_path,
53
+ 'collection' => collection.label.to_s,
54
+ 'page_number' => current_page,
55
+ 'template_data' => Utils.safe_hash(template_item.data),
56
+ 'template_content' => template_item.content.to_s
57
+ }
58
+ )
48
59
  end
49
60
 
50
61
  # Resolves source path for compatibility metadata.
@@ -77,7 +88,10 @@ class Document < Jekyll::Document
77
88
  end
78
89
 
79
90
  data.default_proc = proc do |_, key|
80
- site.frontmatter_defaults.find(relative_path, type, key)
91
+ # Jekyll 3 does not expose `type` as a public Document method, while
92
+ # Jekyll 4 does. The internal type is set immediately above and works
93
+ # consistently with both supported Jekyll versions.
94
+ site.frontmatter_defaults.find(relative_path, @type, key)
81
95
  end
82
96
  end
83
97
  end
@@ -13,15 +13,15 @@ module Pagination
13
13
  # Used by Pagination::Model for every generated page/document.
14
14
  class Paginator < ::Liquid::Drop
15
15
 
16
- attr_reader :per_page, :items, :total_items, :total_indexes, :current, :next, :previous, :first, :last, :trail, :groups
16
+ attr_reader :per_page, :total_item_count, :total_indexes, :current, :next, :previous, :first, :last, :trail, :groups
17
17
 
18
18
  def initialize(per_page:, items:, current_page:, total_pages:, item_keyword:, compatibility: nil, page_windows: nil)
19
19
  @per_page_pattern = Utils.normalise_per_page_pattern(per_page)
20
20
  @current_index_number = current_page.to_i
21
21
  @item_keyword = normalise_item_keyword(item_keyword)
22
22
  @compatibility_mode = normalise_compatibility_mode(compatibility)
23
- @total_items = items.size
24
- @page_windows = normalise_page_windows(page_windows, @total_items)
23
+ @total_item_count = items.size
24
+ @page_windows = normalise_page_windows(page_windows, @total_item_count)
25
25
  requested_total_indexes = [total_pages.to_i, 1].max
26
26
 
27
27
  if @current_index_number > requested_total_indexes
@@ -35,7 +35,7 @@ class Paginator < ::Liquid::Drop
35
35
 
36
36
  current_window = window_for_page_number(@current_index_number)
37
37
  @per_page = current_window['page_size']
38
- @items = items[current_window['offset_start']...current_window['offset_end']] || []
38
+ @page_items = items[current_window['offset_start']...current_window['offset_end']] || []
39
39
  @trail = nil
40
40
  @groups = []
41
41
 
@@ -184,11 +184,12 @@ class Paginator < ::Liquid::Drop
184
184
  #
185
185
  # Liquid rendering uses this Drop instance directly.
186
186
  def to_h
187
- payload = canonical_payload_hash
188
- payload[@item_keyword] = items
189
- payload["total_#{@item_keyword}"] = total_items
187
+ payload = standard_payload_hash
188
+ payload[@item_keyword] = @page_items
189
+ payload["total_#{@item_keyword}"] = @total_item_count
190
190
 
191
191
  if compatibility_mode?
192
+ add_compatibility_item_keys!(payload)
192
193
  payload.merge!(
193
194
  'per_page' => per_page,
194
195
  'page' => page,
@@ -212,19 +213,28 @@ class Paginator < ::Liquid::Drop
212
213
  # Handles dynamic alias keys that are not explicit methods.
213
214
  def liquid_method_missing(method_name)
214
215
  method_key = method_name.to_s
215
- return items if method_key == @item_keyword
216
- return total_items if method_key == "total_#{@item_keyword}"
216
+ return @page_items if method_key == @item_keyword
217
+ return @total_item_count if method_key == "total_#{@item_keyword}"
217
218
 
218
219
  super
219
220
  end
220
221
 
221
222
  private
222
223
 
223
- # Canonical v3 paginator payload hash.
224
- def canonical_payload_hash
224
+ # Restores canonical item keys only for explicitly selected v1/v2
225
+ # compatibility profiles, whose historical paginator APIs require them.
226
+ def add_compatibility_item_keys!(payload)
227
+ canonical_keyword = Config::KEYWORD_DEFAULTS.fetch('items')
228
+ return if @item_keyword == canonical_keyword
229
+
230
+ payload[canonical_keyword] = @page_items
231
+ payload["total_#{canonical_keyword}"] = @total_item_count
232
+ end
233
+
234
+ # Builds the paginator payload shared by every configured item keyword.
235
+ # Item keys themselves are added by `to_h` using the active keyword only.
236
+ def standard_payload_hash
225
237
  {
226
- 'items' => items,
227
- 'total_items' => total_items,
228
238
  'total_indexes' => total_indexes,
229
239
  'current' => current,
230
240
  'next' => self.next,
@@ -275,8 +285,8 @@ class Paginator < ::Liquid::Drop
275
285
  'num' => page_number.to_i,
276
286
  'page_size' => fallback_size,
277
287
  'count' => 0,
278
- 'offset_start' => total_items,
279
- 'offset_end' => total_items,
288
+ 'offset_start' => total_item_count,
289
+ 'offset_end' => total_item_count,
280
290
  'start' => nil,
281
291
  'end' => nil
282
292
  }
@@ -310,10 +320,10 @@ class Paginator < ::Liquid::Drop
310
320
  @page_windows_by_number.values
311
321
  end
312
322
 
313
- # Normalises item alias keyword; falls back to canonical `items`.
323
+ # Normalises the configured item keyword for direct Paginator callers.
314
324
  def normalise_item_keyword(raw_keyword)
315
325
  keyword = raw_keyword.to_s.strip
316
- keyword.empty? ? 'items' : keyword
326
+ keyword.empty? ? Config::KEYWORD_DEFAULTS.fetch('items') : keyword
317
327
  end
318
328
 
319
329
  # Normalises compatibility mode to one supported legacy profile.
@@ -17,7 +17,16 @@ module Query
17
17
  # sources into concrete site items.
18
18
  class Parser
19
19
 
20
- CANONICAL_TYPES = %w[pages all everything].freeze
20
+ # Internal search-source identifiers. Keeping them distinct from collection
21
+ # labels means changing a keyword genuinely releases its former label.
22
+ SEARCH_TYPE_PAGES = '__paginate_v3_search_pages__'.freeze
23
+ SEARCH_TYPE_ALL = '__paginate_v3_search_all__'.freeze
24
+ SEARCH_TYPE_EVERYTHING = '__paginate_v3_search_everything__'.freeze
25
+ SEARCH_TYPE_BY_KEY = {
26
+ 'pages' => SEARCH_TYPE_PAGES,
27
+ 'all' => SEARCH_TYPE_ALL,
28
+ 'everything' => SEARCH_TYPE_EVERYTHING
29
+ }.freeze
21
30
 
22
31
  # Parses a search definition into an array of normalised entries.
23
32
  #
@@ -112,13 +121,13 @@ class Parser
112
121
  end
113
122
  end
114
123
 
115
- # Maps configurable keyword aliases (`pages`, `all`, `everything`)
116
- # to their canonical internal type.
124
+ # Maps only configured keyword aliases to internal search-source types.
125
+ # The former keyword remains an ordinary collection label after a rename.
117
126
  def canonical_type(type, keywords)
118
127
  string_type = type.to_s.strip
119
- return 'pages' if string_type == 'pages' || string_type == keywords['pages']
120
- return 'all' if string_type == 'all' || string_type == keywords['all']
121
- return 'everything' if string_type == 'everything' || string_type == keywords['everything']
128
+ SEARCH_TYPE_BY_KEY.each do |key, internal_type|
129
+ return internal_type if string_type == keywords[key]
130
+ end
122
131
 
123
132
  string_type
124
133
  end
@@ -100,7 +100,7 @@ class Builder
100
100
  pagination_config['enabled'] = true
101
101
 
102
102
  first_target = definition['template_collection'].first
103
- if first_target == 'pages'
103
+ if first_target == Config::COLLECTION_TARGET_PAGES
104
104
  Templates::PageTemplate.new(
105
105
  site: @site,
106
106
  pagination_config: pagination_config,
@@ -45,32 +45,29 @@ class Builder
45
45
  entries
46
46
  end
47
47
 
48
- # Normalises one collection target and coerces template-relative keywords.
48
+ # Normalises one generated-template collection target. Generated templates
49
+ # have no source collection, so all collection-mode keywords become pages.
49
50
  def normalise_collection_entry(raw_entry)
50
51
  entry = raw_entry.to_s.strip
51
52
  return '' if entry.empty?
52
53
 
53
- pages_keyword = @site_config.dig('keywords', 'pages').to_s
54
- self_keyword = @site_config.dig('keywords', 'self').to_s
55
- shadow_keyword = @site_config.dig('keywords', 'shadow').to_s
56
- clone_keyword = @site_config.dig('keywords', 'clone').to_s
54
+ return Config::COLLECTION_TARGET_PAGES if Config::COLLECTION_TARGET_BY_KEY.value?(entry)
57
55
 
58
- return 'pages' if entry == pages_keyword || entry.casecmp('pages').zero?
59
- return 'pages' if entry == self_keyword || entry.casecmp('self').zero?
60
- return 'pages' if entry == shadow_keyword || entry.casecmp('shadow').zero?
61
- return 'pages' if entry == clone_keyword || entry.casecmp('clone').zero?
56
+ Config::COLLECTION_TARGET_BY_KEY.each do |key, _internal_target|
57
+ return Config::COLLECTION_TARGET_PAGES if entry == @site_config.dig('keywords', key).to_s
58
+ end
62
59
 
63
60
  entry
64
61
  end
65
62
 
66
63
  # Resolves default generated-template collection targets from site defaults.
67
64
  #
68
- # `self`, `shadow`, and `clone` are template-relative modes and therefore
69
- # become `pages` during template generation.
65
+ # Template-relative modes become the internal pages target during template
66
+ # generation because a generated template has no source collection.
70
67
  def default_generation_collection
71
68
  raw_collection = @site_config['collection']
72
69
  entries = Utils.arrayify(raw_collection).map { |entry| entry.to_s.strip }.reject(&:empty?)
73
- entries = ['pages'] if entries.empty?
70
+ entries = [Config::COLLECTION_TARGET_PAGES] if entries.empty?
74
71
  if entries.length > 2
75
72
  raise ArgumentError, 'pagination.collection may contain at most two values.'
76
73
  end
@@ -194,9 +194,9 @@ class Builder
194
194
  labels = []
195
195
  Query::Parser.parse(raw_items, @site_config['keywords'], split_delimiter: @split_delimiter).each do |entry|
196
196
  case entry['type']
197
- when 'all', 'everything'
197
+ when Query::Parser::SEARCH_TYPE_ALL, Query::Parser::SEARCH_TYPE_EVERYTHING
198
198
  labels.concat(@site.collections.keys)
199
- when 'pages'
199
+ when Query::Parser::SEARCH_TYPE_PAGES
200
200
  # pages do not map to collections
201
201
  else
202
202
  labels << entry['type'] if @site.collections.key?(entry['type'])
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'digest'
4
-
5
3
  module Jekyll
6
4
  module Plugins
7
5
  module PaginateV3
@@ -18,7 +16,7 @@ class DocumentTemplate < Jekyll::Document
18
16
  # Creates an in-memory collection document seeded from frontmatter and
19
17
  # content supplied by generate config.
20
18
  def initialize(site:, collection:, pagination_config:, frontmatter:, content:, generated_metadata:)
21
- virtual_path = build_virtual_path(site, collection, pagination_config, frontmatter)
19
+ virtual_path = build_virtual_path(site, collection, pagination_config, frontmatter, content)
22
20
  generated_metadata_hash = Utils.safe_hash(generated_metadata)
23
21
 
24
22
  initialise_document(site, collection, virtual_path)
@@ -38,10 +36,21 @@ class DocumentTemplate < Jekyll::Document
38
36
 
39
37
  # Builds a deterministic synthetic source path inside destination
40
38
  # collection.
41
- def build_virtual_path(site, collection, pagination_config, frontmatter)
42
- signature = [collection.label, pagination_config, frontmatter].inspect
43
- filename = "_paginate_v3_generated_#{Digest::MD5.hexdigest(signature)}.md"
44
- File.join(site.source, collection.relative_directory, filename)
39
+ def build_virtual_path(site, collection, pagination_config, frontmatter, content)
40
+ Utils.build_synthetic_source_path(
41
+ site: site,
42
+ collection: collection,
43
+ extension: '.md',
44
+ source_stem: Utils.derive_synthetic_source_stem_from_frontmatter(frontmatter, fallback: collection.label),
45
+ role: 'template',
46
+ signature: {
47
+ 'collection' => collection.label.to_s,
48
+ 'pagination' => Utils.safe_hash(pagination_config),
49
+ 'frontmatter' => Utils.safe_hash(frontmatter),
50
+ 'content' => content.to_s,
51
+ 'content_type' => 'document-template'
52
+ }
53
+ )
45
54
  end
46
55
 
47
56
  # Adds legacy-friendly fields only when v2 compatibility is active.
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'digest'
4
-
5
3
  module Jekyll
6
4
  module Plugins
7
5
  module PaginateV3
@@ -20,7 +18,7 @@ class PageTemplate < Jekyll::Page
20
18
  @dir = '/'
21
19
  @name = 'index.html'
22
20
  @url = '/'
23
- @path = build_virtual_path(site, pagination_config, frontmatter)
21
+ @path = build_virtual_path(site, pagination_config, frontmatter, content)
24
22
 
25
23
  process(@name)
26
24
 
@@ -42,9 +40,19 @@ class PageTemplate < Jekyll::Page
42
40
  private
43
41
 
44
42
  # Builds a deterministic virtual source path for this synthetic page.
45
- def build_virtual_path(site, pagination_config, frontmatter)
46
- signature = [pagination_config, frontmatter].inspect
47
- File.join(site.source, "_paginate_v3_generated_#{Digest::MD5.hexdigest(signature)}.md")
43
+ def build_virtual_path(site, pagination_config, frontmatter, content)
44
+ Utils.build_synthetic_source_path(
45
+ site: site,
46
+ extension: '.md',
47
+ source_stem: Utils.derive_synthetic_source_stem_from_frontmatter(frontmatter, fallback: 'generated-template'),
48
+ role: 'template',
49
+ signature: {
50
+ 'pagination' => Utils.safe_hash(pagination_config),
51
+ 'frontmatter' => Utils.safe_hash(frontmatter),
52
+ 'content' => content.to_s,
53
+ 'content_type' => 'page-template'
54
+ }
55
+ )
48
56
  end
49
57
 
50
58
  # Adds legacy-friendly fields only when v2 compatibility is active.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  module Jekyll
4
6
  module Plugins
5
7
  module PaginateV3
@@ -49,6 +51,99 @@ module Utils
49
51
 
50
52
  url
51
53
  end
54
+
55
+ # Builds one deterministic synthetic source path for in-memory pages
56
+ # and documents. Filenames remain readable for debugging while a hash
57
+ # suffix preserves practical uniqueness across templates and variants.
58
+ def self.build_synthetic_source_path(site:, extension:, signature:, collection: nil, source_path: nil, source_stem: nil, role:, page_number: nil)
59
+ directory = collection.nil? ? site.source : File.join(site.source, collection.relative_directory)
60
+ File.join(
61
+ directory,
62
+ build_synthetic_filename(
63
+ extension: extension,
64
+ signature: signature,
65
+ source_path: source_path,
66
+ source_stem: source_stem,
67
+ role: role,
68
+ page_number: page_number
69
+ )
70
+ )
71
+ end
72
+
73
+ # Builds one readable synthetic filename from source identity plus a
74
+ # deterministic safety suffix.
75
+ def self.build_synthetic_filename(extension:, signature:, source_path: nil, source_stem: nil, role:, page_number: nil)
76
+ stem = derive_synthetic_source_stem(source_path: source_path, source_stem: source_stem, fallback: role)
77
+ filename_segments = [stem, role.to_s.strip]
78
+ filename_segments << page_number.to_i.to_s unless page_number.nil?
79
+ filename_segments << Digest::MD5.hexdigest(canonical_signature(signature).inspect)
80
+ "#{filename_segments.reject(&:empty?).join('-')}#{ensure_leading_dot(extension)}"
81
+ end
82
+
83
+ # Derives one human-readable synthetic stem from an original source
84
+ # path or fallback label.
85
+ def self.derive_synthetic_source_stem(source_path: nil, source_stem: nil, fallback: 'generated')
86
+ candidate = extract_source_filename_stem(source_path)
87
+ candidate = source_stem.to_s if candidate.empty?
88
+ candidate = fallback.to_s if candidate.to_s.strip.empty?
89
+ sanitise_filename_component(candidate, fallback: fallback)
90
+ end
91
+
92
+ # Derives one readable stem from frontmatter when no real source path
93
+ # exists, preferring permalink and then title.
94
+ def self.derive_synthetic_source_stem_from_frontmatter(frontmatter, fallback: 'generated')
95
+ frontmatter_hash = safe_hash(frontmatter)
96
+ permalink_stem = extract_permalink_stem(frontmatter_hash['permalink'])
97
+ return derive_synthetic_source_stem(source_stem: permalink_stem, fallback: fallback) unless permalink_stem.empty?
98
+
99
+ derive_synthetic_source_stem(source_stem: frontmatter_hash['title'], fallback: fallback)
100
+ end
101
+
102
+ # Extracts one source-style stem from a real path-like value.
103
+ def self.extract_source_filename_stem(source_path)
104
+ path = source_path.to_s
105
+ return '' if path.strip.empty?
106
+
107
+ File.basename(path, File.extname(path))
108
+ end
109
+
110
+ # Extracts one final path segment from a permalink-like value.
111
+ def self.extract_permalink_stem(permalink)
112
+ path = permalink.to_s.strip
113
+ return '' if path.empty?
114
+
115
+ segments = path.split('/').reject(&:empty?)
116
+ return '' if segments.empty?
117
+
118
+ File.basename(segments.last, File.extname(segments.last))
119
+ end
120
+
121
+ # Sanitises one filename component while preserving source readability
122
+ # where the original stem is already filesystem-safe.
123
+ def self.sanitise_filename_component(value, fallback: 'generated')
124
+ component = value.to_s.strip
125
+ component = component.gsub(%r{[\\/]}, '-')
126
+ component = component.gsub(/[<>:"|?*\x00-\x1f]/, '-')
127
+ component = component.gsub(/^-+/, '')
128
+ component = component.gsub(/[. ]+$/, '')
129
+ component = fallback.to_s if component.empty?
130
+ component
131
+ end
132
+
133
+ # Canonicalises nested signature data so hashes with identical meaning
134
+ # yield the same suffix regardless of insertion order.
135
+ def self.canonical_signature(value)
136
+ case value
137
+ when Hash
138
+ value.each_with_object({}) do |(key, nested_value), canonical|
139
+ canonical[key.to_s] = canonical_signature(nested_value)
140
+ end.sort_by { |key, _| key }.to_h
141
+ when Array
142
+ value.map { |entry| canonical_signature(entry) }
143
+ else
144
+ value
145
+ end
146
+ end
52
147
  end
53
148
 
54
149
  end
@@ -4,7 +4,7 @@ module Jekyll
4
4
  module Plugins
5
5
 
6
6
  module PaginateV3
7
- VERSION = '0.1.0.alpha.2'
7
+ VERSION = '0.1.0.alpha.3'
8
8
  end
9
9
 
10
10
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-paginate-v3
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.alpha.2
4
+ version: 0.1.0.alpha.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Convincible
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-27 00:00:00.000000000 Z
11
+ date: 2026-08-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -44,6 +44,20 @@ dependencies:
44
44
  - - '='
45
45
  - !ruby/object:Gem::Version
46
46
  version: 0.1.0.alpha
47
+ - !ruby/object:Gem::Dependency
48
+ name: kramdown-parser-gfm
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.1'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.1'
47
61
  - !ruby/object:Gem::Dependency
48
62
  name: pry
49
63
  requirement: !ruby/object:Gem::Requirement