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

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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jekyll-paginate-v3/config/defaults.rb +23 -6
  3. data/lib/jekyll-paginate-v3/config/normaliser/legacy_compatibility.rb +9 -9
  4. data/lib/jekyll-paginate-v3/config/normaliser/site_and_templates.rb +60 -36
  5. data/lib/jekyll-paginate-v3/generators/pagination_generator.rb +2 -2
  6. data/lib/jekyll-paginate-v3/pagination/model/core.rb +29 -11
  7. data/lib/jekyll-paginate-v3/pagination/model/group_navigation_and_pages.rb +80 -20
  8. data/lib/jekyll-paginate-v3/pagination/model/pagination_pipeline.rb +123 -49
  9. data/lib/jekyll-paginate-v3/pagination/pages/document.rb +26 -11
  10. data/lib/jekyll-paginate-v3/pagination/pages/page.rb +3 -2
  11. data/lib/jekyll-paginate-v3/pagination/pages/pager_support.rb +20 -0
  12. data/lib/jekyll-paginate-v3/pagination/paginator/core.rb +27 -17
  13. data/lib/jekyll-paginate-v3/query/parser.rb +18 -8
  14. data/lib/jekyll-paginate-v3/query/sorter.rb +66 -11
  15. data/lib/jekyll-paginate-v3/support/frontmatter_path.rb +20 -5
  16. data/lib/jekyll-paginate-v3/support/placeholder_template.rb +502 -0
  17. data/lib/jekyll-paginate-v3/templates/builder/core.rb +1 -1
  18. data/lib/jekyll-paginate-v3/templates/builder/definition_normalisation.rb +9 -12
  19. data/lib/jekyll-paginate-v3/templates/builder/token_metadata.rb +2 -2
  20. data/lib/jekyll-paginate-v3/templates/document_template.rb +16 -7
  21. data/lib/jekyll-paginate-v3/templates/page_template.rb +14 -6
  22. data/lib/jekyll-paginate-v3/templates/variant_expander.rb +316 -46
  23. data/lib/jekyll-paginate-v3/utils/core.rb +13 -1
  24. data/lib/jekyll-paginate-v3/utils/formatting.rb +65 -25
  25. data/lib/jekyll-paginate-v3/utils/paths.rb +204 -0
  26. data/lib/jekyll-paginate-v3/version.rb +1 -1
  27. data/lib/jekyll-paginate-v3.rb +2 -0
  28. data/readme.md +21 -12
  29. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 036fbbfba18f61ae73a069e4330fce833a1cb7549704c9bf396961ddd262569f
4
- data.tar.gz: 061d674f1bfd29de53bb79bc5a64feb8076a80af38f59be877f3cc77443173ff
3
+ metadata.gz: 800113e7a994aa5e6b1a85ec42a7af7a2d14af9c47e8ad9db98de54763b4b6db
4
+ data.tar.gz: d1e054fdd4621e09fb37f36d98c7009d6d8891e12ce485ef3d6fc0bb3aa7aa99
5
5
  SHA512:
6
- metadata.gz: e6561b1d072672dabdbace597b3b7d310a3f357f7d9b05a1f6815d7b29e634029faf7828ca8105edf6cc41fd1cab276e215de8343358c53e42b482e005f64235
7
- data.tar.gz: 259bd8d452c747203d316a977de290141b11bc1061a39386e0bac81733f1ace2b08ef0b9c1d8092212e232544bb6f8f758e2e49327b6779f54116e555213a7d4
6
+ metadata.gz: 9a955153959b071277e958f5b6d24518a49295b401edd2fc15ee74f282e85e4f3ca5af6ae1be61a5b8af0c72b2a9163d49e6ee4214df11886609c56c29363e9b
7
+ data.tar.gz: 56c0a857ce7e8275c5f4081b427bf6dd6e79ff2ab571cb6446a4d8901c5cc0229c416ed263a9ed497d1737149556dc181b8ef37337209fec743dfa347287f776
@@ -27,6 +27,24 @@ 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
+
44
+ # Slugification modes exposed by pagination config. Every supported mode
45
+ # must produce a route key rather than bypassing character filtering.
46
+ SLUGIFY_MODES = %w[default ascii latin].freeze
47
+
30
48
  DEFAULTS = {
31
49
  'enabled' => true,
32
50
  'compatibility' => nil,
@@ -41,15 +59,15 @@ module Config
41
59
  ['category', 'categories']
42
60
  ],
43
61
  'items' => nil,
44
- 'collection' => ['self', 'shadow'],
62
+ 'collection' => [COLLECTION_TARGET_SELF, COLLECTION_TARGET_SHADOW],
45
63
  'filters' => nil,
46
64
  'sort' => 'date desc',
47
65
  'per_page' => 10,
48
66
  'limit' => 0,
49
67
  'offset' => 0,
50
68
  'trail' => 5,
51
- 'title' => ':title - :num',
52
- 'permalink' => ':num',
69
+ 'title' => '{{ title }} - {{ num }}',
70
+ 'permalink' => '{{ num }}',
53
71
  'layout' => nil,
54
72
  'layouts' => [],
55
73
  'group' => nil,
@@ -58,7 +76,7 @@ module Config
58
76
  'lowercase' => true
59
77
  },
60
78
  'templates' => {
61
- 'location' => 'pages',
79
+ 'location' => nil,
62
80
  'generate' => []
63
81
  }
64
82
  }.freeze
@@ -82,7 +100,7 @@ module Config
82
100
  },
83
101
  'items' => 'posts',
84
102
  'title' => ':title - page :num',
85
- 'permalink' => '/page/:num/',
103
+ 'permalink' => 'page/:num/',
86
104
  'trail' => {
87
105
  'before' => 2,
88
106
  'after' => 2
@@ -95,7 +113,6 @@ module Config
95
113
  },
96
114
  'items' => 'posts',
97
115
  'templates' => {
98
- 'location' => 'pages',
99
116
  'generate' => []
100
117
  }
101
118
  }
@@ -43,7 +43,7 @@ class Normaliser
43
43
  extension = override_hash.key?('extension') ? override_hash['extension'].to_s : 'html'
44
44
 
45
45
  template_config['page_templates'] ||= build_page_templates(template_config['title'], template_config['permalink'])
46
- template_config['page_templates']['page1']['permalink'] = Utils.ensure_full_path('/', index_name, extension)
46
+ template_config['page_templates']['page1']['permalink'] = Utils.ensure_full_path(index_name, index_name, extension)
47
47
  template_config['page_templates']['page2']['permalink'] = Utils.ensure_full_path(template_config['permalink'], index_name, extension)
48
48
  end
49
49
 
@@ -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
  ))
@@ -116,11 +118,9 @@ class Normaliser
116
118
  permalink = group['permalink']
117
119
  permalink = defaults['permalink'] unless present_config_value?(permalink)
118
120
 
119
- slugify = if group.key?('slugify')
120
- Utils.safe_hash(group['slugify'])
121
- else
122
- Utils.deep_copy(defaults['slugify'])
123
- end
121
+ slugify = normalise_slugify_config(
122
+ group.key?('slugify') ? group['slugify'] : Utils.deep_copy(defaults['slugify'])
123
+ )
124
124
 
125
125
  [
126
126
  {
@@ -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,17 +189,19 @@ 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
- source['generate'].map { |entry| Utils.safe_hash(entry) }
202
+ source['generate'].map { |entry| normalise_generated_template_definition(entry) }
200
203
  elsif source['generate'].is_a?(Hash)
201
- [Utils.safe_hash(source['generate'])]
204
+ [normalise_generated_template_definition(source['generate'])]
202
205
  else
203
206
  []
204
207
  end
@@ -209,6 +212,17 @@ class Normaliser
209
212
  source
210
213
  end
211
214
 
215
+ # Validates settings that generated templates otherwise would not
216
+ # normalise until their in-memory template objects are processed.
217
+ def normalise_generated_template_definition(raw_definition)
218
+ definition = Utils.safe_hash(raw_definition)
219
+ if definition.key?('slugify')
220
+ definition['slugify'] = normalise_slugify_config(definition['slugify'])
221
+ end
222
+
223
+ definition
224
+ end
225
+
212
226
  # Returns site-level defaults that are inherited by explicit and
213
227
  # generated templates.
214
228
  def extract_site_template_defaults(site_config)
@@ -344,7 +358,7 @@ class Normaliser
344
358
  def normalise_collection_targets(raw_collection, split_delimiter:, keywords:)
345
359
  default_targets = Utils.deep_copy(DEFAULTS['collection'])
346
360
  entries = Utils.delimited_array(raw_collection, delimiter: split_delimiter)
347
- entries = Utils.deep_copy(default_targets) if entries.empty?
361
+ return default_targets if entries.empty?
348
362
 
349
363
  normalised = entries.map do |entry|
350
364
  normalise_collection_target_entry(entry, keywords)
@@ -363,21 +377,12 @@ class Normaliser
363
377
  entry = raw_entry.to_s.strip
364
378
  return '' if entry.empty?
365
379
 
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|
380
+ COLLECTION_TARGET_BY_KEY.each do |key, internal_target|
381
+ keyword = keywords[key]
374
382
  next if keyword.to_s.empty?
375
- return canonical if entry == keyword
383
+ return internal_target if entry == keyword
376
384
  end
377
385
 
378
- canonical = entry.downcase
379
- return canonical if %w[pages self shadow clone].include?(canonical)
380
-
381
386
  entry
382
387
  end
383
388
 
@@ -388,7 +393,7 @@ class Normaliser
388
393
  def build_page_templates(page2_title, page2_permalink)
389
394
  {
390
395
  'page1' => {
391
- 'title' => ':title',
396
+ 'title' => '{{ title }}',
392
397
  'permalink' => ''
393
398
  },
394
399
  'page2' => {
@@ -460,7 +465,7 @@ class Normaliser
460
465
  # Preserves legacy `sort_field` + `sort_reverse` behaviour when the
461
466
  # caller did not provide an explicit `sort` override.
462
467
  def normalise_sort(raw_sort, raw_sort_field, raw_sort_reverse, split_delimiter, sort_explicitly_set: false)
463
- sort_entries = Utils.arrayify(raw_sort, split_delimiter: split_delimiter).map(&:to_s).map(&:strip).reject(&:empty?)
468
+ sort_entries = placeholder_aware_sort_entries(raw_sort, split_delimiter)
464
469
  sort_field = raw_sort_field.to_s.strip
465
470
 
466
471
  if !sort_explicitly_set && !sort_field.empty?
@@ -472,13 +477,25 @@ class Normaliser
472
477
 
473
478
  if sort_field.empty?
474
479
  fallback_sort = DEFAULTS['sort']
475
- return Utils.arrayify(fallback_sort, split_delimiter: split_delimiter).map(&:to_s).map(&:strip).reject(&:empty?)
480
+ return placeholder_aware_sort_entries(fallback_sort, split_delimiter)
476
481
  end
477
482
 
478
483
  direction = boolean_config_value(raw_sort_reverse) ? 'desc' : 'asc'
479
484
  ["#{sort_field} #{direction}"]
480
485
  end
481
486
 
487
+ # Splits sort lists without treating a canonical placeholder filter pipe
488
+ # as the configured list separator.
489
+ def placeholder_aware_sort_entries(raw_sort, split_delimiter)
490
+ raw_entries = raw_sort.is_a?(Array) ? raw_sort.flatten : [raw_sort]
491
+ raw_entries.flat_map do |raw_entry|
492
+ Support::PlaceholderTemplate.split_source(
493
+ raw_entry,
494
+ delimiter: split_delimiter
495
+ )
496
+ end.map(&:to_s).map(&:strip).reject(&:empty?)
497
+ end
498
+
482
499
  # Normalises `pagination.layout` / `pagination.layouts` into a
483
500
  # canonical string array.
484
501
  def normalise_layout_overrides(config, split_delimiter:)
@@ -505,9 +522,17 @@ class Normaliser
505
522
  [raw_group]
506
523
  end
507
524
 
508
- raw_entries.map do |raw_entry|
525
+ entries = raw_entries.map do |raw_entry|
509
526
  normalise_group_entry(raw_entry)
510
527
  end.compact
528
+
529
+ keys = entries.map { |entry| entry['on'].to_s }
530
+ duplicate_keys = keys.group_by(&:itself).select { |_, matches| matches.length > 1 }.keys
531
+ unless duplicate_keys.empty?
532
+ raise ArgumentError, "Duplicate pagination group key(s): #{duplicate_keys.sort.join(', ')}. Each `group.on` key must be unique."
533
+ end
534
+
535
+ entries
511
536
  end
512
537
 
513
538
  # Normalises legacy `index` + `group` + `filter` config into modern
@@ -577,26 +602,25 @@ class Normaliser
577
602
  { 'on' => on_key }
578
603
  end
579
604
 
580
- # Normalises slugify config accepted on template pagination config.
581
- # This supports `slugify.lowercase` semantics while also allowing
582
- # string shorthand where the value maps directly to `mode`.
605
+ # Normalises slugify config into the route-key policy shared by grouping
606
+ # and slugified placeholder representations.
583
607
  def normalise_slugify_config(raw_slugify)
584
- if raw_slugify.is_a?(String)
585
- mode = raw_slugify.to_s.strip
586
- mode = 'default' if mode.empty?
587
- return {
588
- 'mode' => mode,
589
- 'lowercase' => true
590
- }
591
- end
592
-
593
- slugify = Utils.safe_hash(raw_slugify)
608
+ slugify = raw_slugify.is_a?(String) ? { 'mode' => raw_slugify } : Utils.safe_hash(raw_slugify)
594
609
  mode = slugify['mode'].to_s.strip
595
610
  mode = 'default' if mode.empty?
611
+ unless SLUGIFY_MODES.include?(mode)
612
+ raise ArgumentError, "`slugify.mode` must be one of #{SLUGIFY_MODES.join(', ')}; received '#{mode}'."
613
+ end
614
+
615
+ lowercase = if slugify.key?('lowercase')
616
+ boolean_config_value(slugify['lowercase'])
617
+ else
618
+ true
619
+ end
596
620
 
597
621
  {
598
622
  'mode' => mode,
599
- 'lowercase' => boolean_config_value(slugify['lowercase'])
623
+ 'lowercase' => lowercase
600
624
  }
601
625
  end
602
626
 
@@ -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 = []
@@ -72,8 +72,6 @@ class Model
72
72
  end
73
73
  next
74
74
  end
75
- validate_required_template_config!(template, template_config)
76
-
77
75
  enabled_templates << [template, template_config, template_pagination_source, template_log_lambda]
78
76
  end
79
77
 
@@ -177,9 +175,28 @@ class Model
177
175
  )
178
176
  end
179
177
 
178
+ # Keeps the captured item-resolution view immutable when a source
179
+ # template itself is retained and mutated into pagination page one.
180
+ # Later templates should still see the source template metadata rather
181
+ # than the emitted index metadata attached to the retained object.
182
+ def replace_item_resolution_source!(source_item, source_snapshot)
183
+ @item_resolution_pages&.map! do |item|
184
+ item.equal?(source_item) ? source_snapshot : item
185
+ end
186
+
187
+ @item_resolution_documents_by_collection&.each_value do |documents|
188
+ documents.map! do |item|
189
+ item.equal?(source_item) ? source_snapshot : item
190
+ end
191
+ end
192
+ end
193
+
180
194
  # Discovers all pages/documents configured as pagination templates.
181
195
  def discover_templates
182
196
  search_entries = Query::Parser.parse(@site_config.dig('templates', 'location'), @site_config['keywords'], split_delimiter: @split_delimiter)
197
+ # Report site pages before collection sources regardless of configuration order.
198
+ page_search_entries, collection_search_entries = search_entries.partition { |entry| entry['type'] == Query::Parser::SEARCH_TYPE_PAGES }
199
+ search_entries = page_search_entries + collection_search_entries
183
200
  reset_template_search_reporting_state
184
201
 
185
202
  combined_candidates = []
@@ -321,10 +338,11 @@ class Model
321
338
  def find_layout(layout_name)
322
339
  layout = @site.layouts[layout_name]
323
340
  return layout unless layout.nil?
324
- return nil unless layout_name.include?('.')
325
341
 
326
- basename = File.basename(layout_name, File.extname(layout_name))
327
- @site.layouts[basename]
342
+ normalised_layout_name = Utils.normalise_layout_name(layout_name)
343
+ return nil if normalised_layout_name == layout_name
344
+
345
+ @site.layouts[normalised_layout_name]
328
346
  end
329
347
 
330
348
  # Resolves template compatibility mode from local and site config.
@@ -490,13 +508,13 @@ class Model
490
508
  [Utils.item_collection_label(item).to_s, Utils.relative_item_path(item)]
491
509
  end
492
510
 
493
- # Resolves one parsed search entry (`pages`, collection label, etc).
511
+ # Resolves one parsed search entry (a private source type or collection label).
494
512
  def resolve_entry(entry)
495
513
  type = entry['type']
496
514
  paths = entry['paths']
497
515
  source_items = source_items_for_entry_type(type)
498
516
  if source_items.nil?
499
- log("Search entry type='#{type}' did not match pages/all/everything or a known collection; resolved 0 items.", 'warn')
517
+ log("Search entry type='#{type}' did not match a configured search keyword or known collection; resolved 0 items.", 'warn')
500
518
  return []
501
519
  end
502
520
 
@@ -512,11 +530,11 @@ class Model
512
530
  # Resolves source items for one parsed search entry type.
513
531
  def source_items_for_entry_type(type)
514
532
  case type
515
- when 'pages'
533
+ when Query::Parser::SEARCH_TYPE_PAGES
516
534
  @item_resolution_pages || @site.pages
517
- when 'all'
535
+ when Query::Parser::SEARCH_TYPE_ALL
518
536
  all_collection_documents
519
- when 'everything'
537
+ when Query::Parser::SEARCH_TYPE_EVERYTHING
520
538
  (@item_resolution_pages || @site.pages) + all_collection_documents
521
539
  else
522
540
  return @item_resolution_documents_by_collection[type] if @item_resolution_documents_by_collection&.key?(type)
@@ -94,9 +94,14 @@ class Model
94
94
  def grouped_set_sort_direction(config, index_key)
95
95
  return 'asc' if index_key.to_s.strip.empty?
96
96
 
97
- split_delimiter = config.key?('split') ? config['split'] : @split_delimiter
98
- sort_instructions = Query::Sorter.parse(config['sort'], split_delimiter: split_delimiter)
99
- sort_entry = sort_instructions.find { |entry| entry['field'] == index_key }
97
+ sort_instructions = config['_sort_instructions']
98
+ if sort_instructions.nil?
99
+ split_delimiter = config.key?('split') ? config['split'] : @split_delimiter
100
+ sort_instructions = Query::Sorter.parse(config['sort'], split_delimiter: split_delimiter)
101
+ end
102
+ sort_entry = sort_instructions.find do |entry|
103
+ (entry['source_field'] || entry['field']) == index_key
104
+ end
100
105
  return 'asc' if sort_entry.nil?
101
106
 
102
107
  sort_entry['direction']
@@ -115,33 +120,61 @@ class Model
115
120
 
116
121
  # Applies configured page title templates.
117
122
  def assign_generated_page_title!(generated, template, config, current_page, total_pages)
118
- page_template = page_template_config(config, current_page)
119
123
  base_title = template.data['title'] || @site.config['title']
120
- generated.data['title'] = Utils.format_page_title(page_template['title'], base_title, current_page, total_pages)
124
+ pattern = page_placeholder_template(config, current_page, 'title')
125
+ generated.data['title'] = Utils.format_page_title(
126
+ pattern,
127
+ base_title,
128
+ current_page,
129
+ total_pages,
130
+ slugifier: placeholder_slugifier(config)
131
+ )
121
132
  end
122
133
 
123
134
  # Applies configured page permalink templates.
124
135
  def assign_generated_page_permalink!(generated, template, config, current_page, total_pages)
125
- resolved_permalink = resolved_page_permalink(template, config, current_page, total_pages)
136
+ page_route_fragment = resolved_page_route_fragment(config, current_page, total_pages)
137
+ page_route_path = Utils.join_route_fragments(page_route_fragment)
138
+ resolved_permalink = resolved_page_permalink(template, config, current_page, page_route_fragment)
139
+ reset_cached_item_url!(generated)
126
140
 
127
141
  if resolved_permalink.nil?
128
142
  generated.data.delete('permalink') if current_page > 1
129
- return
143
+ return page_route_path
130
144
  end
131
145
 
132
- generated.data['permalink'] = resolved_permalink
146
+ context = "pagination page #{current_page} for template '#{Utils.relative_item_path(template)}'"
147
+ generated.data['permalink'] = Utils.validate_resolved_permalink!(resolved_permalink, context: context)
148
+ Utils.validate_output_destination!(generated, site: @site, context: context)
149
+ page_route_path
133
150
  end
134
151
 
135
- # Resolves one page permalink from page1/page2 template settings.
136
- def resolved_page_permalink(template, config, current_page, total_pages)
137
- page_template = page_template_config(config, current_page)
138
- template_permalink = Utils.format_page_number(page_template['permalink'], current_page, total_pages)
139
- return Utils.ensure_leading_slash(template_permalink) if v1_absolute_paginate_path?(config, current_page)
152
+ # Resolves the relative route fragment contributed by the current index.
153
+ def resolved_page_route_fragment(config, current_page, total_pages)
154
+ pattern = page_placeholder_template(config, current_page, 'permalink')
155
+ Utils.format_page_number(
156
+ pattern,
157
+ current_page,
158
+ total_pages,
159
+ slugifier: placeholder_slugifier(config)
160
+ )
161
+ end
162
+
163
+ # Resolves one complete page permalink from the template route and already
164
+ # interpolated page fragment.
165
+ def resolved_page_permalink(template, config, current_page, page_route_fragment)
166
+ return Utils.ensure_leading_slash(page_route_fragment) if v1_absolute_paginate_path?(config, current_page)
140
167
 
141
168
  first_page_url = template_first_page_url(template)
142
- return first_page_url if template_permalink.to_s.strip.empty?
169
+ return first_page_url if page_route_fragment.to_s.strip.empty?
170
+
171
+ join_url(first_page_url, page_route_fragment)
172
+ end
143
173
 
144
- join_url(first_page_url, template_permalink)
174
+ # Clears Jekyll's memoised URL before a retained source object receives its
175
+ # final permalink. Generated adapters already start with an empty cache.
176
+ def reset_cached_item_url!(item)
177
+ item.instance_variable_set(:@url, nil) if item.instance_variable_defined?(:@url)
145
178
  end
146
179
 
147
180
  # Determines whether this page should use v1-style absolute paginate_path.
@@ -161,7 +194,7 @@ class Model
161
194
 
162
195
  if current_page == 1
163
196
  {
164
- 'title' => ':title',
197
+ 'title' => '{{ title }}',
165
198
  'permalink' => ''
166
199
  }
167
200
  else
@@ -172,15 +205,42 @@ class Model
172
205
  end
173
206
  end
174
207
 
175
- # Ensures multi-page pagination outputs include `:num` in page2 permalink
208
+ # Returns the already-parsed pattern for one page-template field, falling
209
+ # back to its public scalar for compatibility callers without variant state.
210
+ def page_placeholder_template(config, current_page, field)
211
+ key = current_page == 1 ? 'page1' : 'page2'
212
+ parsed = config.dig('_placeholder_templates', 'page_templates', key, field)
213
+ return parsed unless parsed.nil?
214
+
215
+ page_template_config(config, current_page)[field]
216
+ end
217
+
218
+ # Builds the representation filter used by system values in this template.
219
+ def placeholder_slugifier(config)
220
+ slugify = Utils.safe_hash(config['slugify'])
221
+ mode = slugify['mode'].to_s.strip
222
+ mode = 'default' if mode.empty?
223
+ lowercase = !!slugify['lowercase']
224
+ lambda do |value|
225
+ Jekyll::Utils.slugify(value.to_s, mode: mode, cased: !lowercase)
226
+ end
227
+ end
228
+
229
+ # Ensures multi-page pagination outputs include a page-number placeholder
176
230
  # templates so each generated index resolves to a unique destination path.
177
231
  def validate_numbered_permalink_template!(template, config, total_pages)
178
232
  return unless total_pages > 1
179
233
 
180
234
  page2_permalink = page_template_config(config, 2)['permalink'].to_s
181
- return if page2_permalink.include?(':num')
182
-
183
- raise ArgumentError, "Template '#{Utils.relative_item_path(template)}' paginates to #{total_pages} pages but page2 permalink template '#{page2_permalink}' (from `pagination.page_templates.page2.permalink` or fallback `pagination.permalink`) does not include ':num'. Add ':num' so generated indexes have unique permalinks."
235
+ parsed_permalink = page_placeholder_template(config, 2, 'permalink')
236
+ parsed_permalink = Utils.placeholder_template(
237
+ page2_permalink,
238
+ allowed: %w[num max],
239
+ context: 'pagination page2 permalink'
240
+ ) unless parsed_permalink.is_a?(Support::PlaceholderTemplate)
241
+ return if parsed_permalink.include_placeholder?('num')
242
+
243
+ raise ArgumentError, "Template '#{Utils.relative_item_path(template)}' paginates to #{total_pages} pages but page2 permalink template '#{page2_permalink}' (from `pagination.page_templates.page2.permalink` or fallback `pagination.permalink`) does not include '{{ num }}' or ':num'. Add a page-number placeholder so generated indexes have unique permalinks."
184
244
  end
185
245
 
186
246
  # Determines the canonical URL for the first pagination page of a template.