jekyll-paginate-v3 0.1.0.alpha.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4167a9c3e76eb08e45275f31be52227e30a6b6d453f1bceef0c901097d4250f7
4
- data.tar.gz: e8e96586a43ff094865264446fc5389555438c74e87dbeb38f5c0c818b2ed47e
3
+ metadata.gz: 800113e7a994aa5e6b1a85ec42a7af7a2d14af9c47e8ad9db98de54763b4b6db
4
+ data.tar.gz: d1e054fdd4621e09fb37f36d98c7009d6d8891e12ce485ef3d6fc0bb3aa7aa99
5
5
  SHA512:
6
- metadata.gz: 457fc67c2920f51aac38003b395720365df59f5786a9fb71f1d34c6d68d5a279abb32f10608b3d4c2c66b93934f8108e26175165795bb4a253c43898eb89d12d
7
- data.tar.gz: a44bb5a0591d319502f0f574d5fa0493a4636477e7a8301ba9a9c5fc712b74f1564b169617b15c950512f52f29d2a23d3b6207c86b8d09cddbd182f81e4fdca3
6
+ metadata.gz: 9a955153959b071277e958f5b6d24518a49295b401edd2fc15ee74f282e85e4f3ca5af6ae1be61a5b8af0c72b2a9163d49e6ee4214df11886609c56c29363e9b
7
+ data.tar.gz: 56c0a857ce7e8275c5f4081b427bf6dd6e79ff2ab571cb6446a4d8901c5cc0229c416ed263a9ed497d1737149556dc181b8ef37337209fec743dfa347287f776
@@ -41,6 +41,10 @@ module Config
41
41
  'clone' => COLLECTION_TARGET_CLONE
42
42
  }.freeze
43
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
+
44
48
  DEFAULTS = {
45
49
  'enabled' => true,
46
50
  'compatibility' => nil,
@@ -62,8 +66,8 @@ module Config
62
66
  'limit' => 0,
63
67
  'offset' => 0,
64
68
  'trail' => 5,
65
- 'title' => ':title - :num',
66
- 'permalink' => ':num',
69
+ 'title' => '{{ title }} - {{ num }}',
70
+ 'permalink' => '{{ num }}',
67
71
  'layout' => nil,
68
72
  'layouts' => [],
69
73
  'group' => nil,
@@ -96,7 +100,7 @@ module Config
96
100
  },
97
101
  'items' => 'posts',
98
102
  'title' => ':title - page :num',
99
- 'permalink' => '/page/:num/',
103
+ 'permalink' => 'page/:num/',
100
104
  'trail' => {
101
105
  'before' => 2,
102
106
  'after' => 2
@@ -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
 
@@ -118,11 +118,9 @@ class Normaliser
118
118
  permalink = group['permalink']
119
119
  permalink = defaults['permalink'] unless present_config_value?(permalink)
120
120
 
121
- slugify = if group.key?('slugify')
122
- Utils.safe_hash(group['slugify'])
123
- else
124
- Utils.deep_copy(defaults['slugify'])
125
- end
121
+ slugify = normalise_slugify_config(
122
+ group.key?('slugify') ? group['slugify'] : Utils.deep_copy(defaults['slugify'])
123
+ )
126
124
 
127
125
  [
128
126
  {
@@ -199,9 +199,9 @@ class Normaliser
199
199
  # does not make the default location collide with a collection label.
200
200
  source['location'] = keywords['pages'] if source['location'].nil? || source['location'].to_s.strip.empty?
201
201
  source['generate'] = if source['generate'].is_a?(Array)
202
- source['generate'].map { |entry| Utils.safe_hash(entry) }
202
+ source['generate'].map { |entry| normalise_generated_template_definition(entry) }
203
203
  elsif source['generate'].is_a?(Hash)
204
- [Utils.safe_hash(source['generate'])]
204
+ [normalise_generated_template_definition(source['generate'])]
205
205
  else
206
206
  []
207
207
  end
@@ -212,6 +212,17 @@ class Normaliser
212
212
  source
213
213
  end
214
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
+
215
226
  # Returns site-level defaults that are inherited by explicit and
216
227
  # generated templates.
217
228
  def extract_site_template_defaults(site_config)
@@ -382,7 +393,7 @@ class Normaliser
382
393
  def build_page_templates(page2_title, page2_permalink)
383
394
  {
384
395
  'page1' => {
385
- 'title' => ':title',
396
+ 'title' => '{{ title }}',
386
397
  'permalink' => ''
387
398
  },
388
399
  'page2' => {
@@ -454,7 +465,7 @@ class Normaliser
454
465
  # Preserves legacy `sort_field` + `sort_reverse` behaviour when the
455
466
  # caller did not provide an explicit `sort` override.
456
467
  def normalise_sort(raw_sort, raw_sort_field, raw_sort_reverse, split_delimiter, sort_explicitly_set: false)
457
- 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)
458
469
  sort_field = raw_sort_field.to_s.strip
459
470
 
460
471
  if !sort_explicitly_set && !sort_field.empty?
@@ -466,13 +477,25 @@ class Normaliser
466
477
 
467
478
  if sort_field.empty?
468
479
  fallback_sort = DEFAULTS['sort']
469
- 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)
470
481
  end
471
482
 
472
483
  direction = boolean_config_value(raw_sort_reverse) ? 'desc' : 'asc'
473
484
  ["#{sort_field} #{direction}"]
474
485
  end
475
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
+
476
499
  # Normalises `pagination.layout` / `pagination.layouts` into a
477
500
  # canonical string array.
478
501
  def normalise_layout_overrides(config, split_delimiter:)
@@ -499,9 +522,17 @@ class Normaliser
499
522
  [raw_group]
500
523
  end
501
524
 
502
- raw_entries.map do |raw_entry|
525
+ entries = raw_entries.map do |raw_entry|
503
526
  normalise_group_entry(raw_entry)
504
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
505
536
  end
506
537
 
507
538
  # Normalises legacy `index` + `group` + `filter` config into modern
@@ -571,26 +602,25 @@ class Normaliser
571
602
  { 'on' => on_key }
572
603
  end
573
604
 
574
- # Normalises slugify config accepted on template pagination config.
575
- # This supports `slugify.lowercase` semantics while also allowing
576
- # 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.
577
607
  def normalise_slugify_config(raw_slugify)
578
- if raw_slugify.is_a?(String)
579
- mode = raw_slugify.to_s.strip
580
- mode = 'default' if mode.empty?
581
- return {
582
- 'mode' => mode,
583
- 'lowercase' => true
584
- }
585
- end
586
-
587
- slugify = Utils.safe_hash(raw_slugify)
608
+ slugify = raw_slugify.is_a?(String) ? { 'mode' => raw_slugify } : Utils.safe_hash(raw_slugify)
588
609
  mode = slugify['mode'].to_s.strip
589
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
590
620
 
591
621
  {
592
622
  'mode' => mode,
593
- 'lowercase' => boolean_config_value(slugify['lowercase'])
623
+ 'lowercase' => lowercase
594
624
  }
595
625
  end
596
626
 
@@ -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.
@@ -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.
@@ -14,9 +14,17 @@ class Model
14
14
 
15
15
  def paginate_template(template, config, template_pagination_source)
16
16
  item_exclusions = pagination_item_exclusions_for_template(template)
17
- variants = expand_template_variants(template, config, template_pagination_source: template_pagination_source, item_exclusions: item_exclusions)
17
+ template_route = template_first_page_url(template)
18
+ variants = expand_template_variants(
19
+ template,
20
+ config,
21
+ template_route: template_route,
22
+ template_pagination_source: template_pagination_source,
23
+ item_exclusions: item_exclusions
24
+ )
18
25
  if variants.empty?
19
- log("Template '#{Utils.relative_item_path(template)}': grouping/layout expansion produced no variants.", 'debug')
26
+ @remove_item_lambda.call(template)
27
+ log("Template '#{Utils.relative_item_path(template)}': grouping/layout expansion produced no variants; consumed template without emitting indexes.", 'debug')
20
28
  return {
21
29
  'paginated_items' => 0,
22
30
  'indexes' => 0
@@ -26,6 +34,7 @@ class Model
26
34
  total_paginated_items = 0
27
35
  total_indexes = 0
28
36
  collection_replacement_state = nil
37
+ retain_page_one = variants.one? && retain_source_template_as_page_one?(template, variants.first['config'])
29
38
 
30
39
  variants.each_with_index do |variant, variant_index|
31
40
  variant_template = variant['template']
@@ -33,7 +42,10 @@ class Model
33
42
  variant_report = paginate_template_variant(
34
43
  variant_template,
35
44
  variant_config,
36
- remove_source_template: variant_index.zero?,
45
+ remove_source_template: variant_index.zero? && !retain_page_one,
46
+ retain_page_one: retain_page_one,
47
+ route_base: variant['route_base'],
48
+ route_path: variant['route_path'],
37
49
  item_exclusions: item_exclusions,
38
50
  collection_replacement_state: collection_replacement_state
39
51
  )
@@ -56,34 +68,28 @@ class Model
56
68
  end
57
69
 
58
70
  # Expands one template into grouped/layout variants before pagination.
59
- def expand_template_variants(template, config, template_pagination_source:, item_exclusions:)
71
+ def expand_template_variants(template, config, template_route:, template_pagination_source:, item_exclusions:)
60
72
  expander = Templates::VariantExpander.new(
61
73
  site: @site,
62
74
  site_config: @site_config,
63
75
  template: template,
76
+ template_route: template_route,
64
77
  template_config: config,
65
78
  template_pagination_source: template_pagination_source,
66
79
  merge_template_pagination_lambda: method(:merged_template_pagination_config),
67
80
  normalise_template_config_lambda: lambda { |pagination| Config::Normaliser.normalise_template_config(@site_config, pagination) },
81
+ validate_template_config_lambda: method(:validate_required_template_config!),
68
82
  resolve_items_lambda: lambda { |raw_search| resolve_items(raw_search, exclude_items: item_exclusions) },
69
83
  log_lambda: @active_log_lambda
70
84
  )
71
- variants = expander.expand
72
- return variants unless variants.empty?
73
-
74
- template.data['pagination'] = merged_template_pagination_config(template, template_pagination_source)
75
-
76
- [
77
- {
78
- 'template' => template,
79
- 'config' => config
80
- }
81
- ]
85
+ expander.expand
82
86
  end
83
87
 
84
88
  # Runs pagination for one already-expanded template variant.
85
- def paginate_template_variant(template, config, remove_source_template:, item_exclusions:, collection_replacement_state:)
89
+ def paginate_template_variant(template, config, remove_source_template:, retain_page_one:, route_base:, route_path:, item_exclusions:, collection_replacement_state:)
86
90
  template_path = Utils.relative_item_path(template)
91
+ emission_template = retain_page_one ? clone_template_for_emission(template) : template
92
+ replace_item_resolution_source!(template, emission_template) if retain_page_one
87
93
  split_delimiter = config.key?('split') ? config['split'] : @split_delimiter
88
94
  nested_separator = config['separator'] || @nested_separator
89
95
  all_items = resolve_items(config['items'], exclude_items: item_exclusions)
@@ -108,7 +114,8 @@ class Model
108
114
  config['sort'],
109
115
  nested_separator: nested_separator,
110
116
  equivalents: @equivalents,
111
- split_delimiter: split_delimiter
117
+ split_delimiter: split_delimiter,
118
+ instructions: config['_sort_instructions']
112
119
  )
113
120
  log("Template '#{template_path}': sorted #{sorted_items.length} item(s) by #{config['sort']} before offset.", 'debug')
114
121
  log_item_path_sample("Template '#{template_path}': sorted item sample", sorted_items)
@@ -127,20 +134,23 @@ class Model
127
134
 
128
135
  page_windows = Utils.build_pagination_windows(sorted_items.length, config['per_page'])
129
136
  total_pages = page_windows.length
130
- validate_numbered_permalink_template!(template, config, total_pages)
137
+ validate_numbered_permalink_template!(emission_template, config, total_pages)
131
138
 
132
139
  log("Template '#{template_path}': generating #{total_pages} page(s) with per_page=#{config['per_page']} limit=#{config['limit']}.", 'debug')
133
140
  page_emission = emit_paginated_pages(
134
- template,
141
+ emission_template,
135
142
  config,
136
143
  sorted_items,
137
144
  page_windows,
138
145
  remove_template: remove_source_template,
146
+ retained_page_one: retain_page_one ? template : nil,
147
+ route_base: route_base,
148
+ route_path: route_path,
139
149
  collection_replacement_state: collection_replacement_state
140
150
  )
141
151
  generated_pages = page_emission['pages']
142
152
  collection_replacement_state = page_emission['collection_replacement_state']
143
- register_grouped_set_if_applicable(template, config, generated_pages)
153
+ register_grouped_set_if_applicable(emission_template, config, generated_pages)
144
154
  {
145
155
  'paginated_items' => sorted_items.length,
146
156
  'indexes' => generated_pages.length,
@@ -149,10 +159,12 @@ class Model
149
159
  end
150
160
 
151
161
  # Replaces a template with one synthetic page/document per page number.
152
- def emit_paginated_pages(template, config, items, page_windows, remove_template: true, collection_replacement_state: nil)
162
+ def emit_paginated_pages(template, config, items, page_windows, remove_template: true, retained_page_one: nil, route_base:, route_path:, collection_replacement_state: nil)
153
163
  if remove_template
154
164
  removed_item_state = @remove_item_lambda.call(template)
155
165
  collection_replacement_state = build_collection_replacement_state(template, removed_item_state)
166
+ elsif !retained_page_one.nil?
167
+ collection_replacement_state = build_retained_collection_insertion_state(retained_page_one)
156
168
  end
157
169
 
158
170
  new_pages = []
@@ -164,13 +176,18 @@ class Model
164
176
 
165
177
  page_windows.each do |page_window|
166
178
  current_page = page_window['num']
167
- generated = build_generated_item(
168
- template: template,
169
- config: config,
170
- current_page: current_page,
171
- total_pages: total_pages,
172
- index_file: index_file
173
- )
179
+ retaining_current_page = current_page == 1 && !retained_page_one.nil?
180
+ generated = if retaining_current_page
181
+ prepare_retained_page_one(retained_page_one, current_page: current_page, total_pages: total_pages)
182
+ else
183
+ build_generated_item(
184
+ template: template,
185
+ config: config,
186
+ current_page: current_page,
187
+ total_pages: total_pages,
188
+ index_file: index_file
189
+ )
190
+ end
174
191
 
175
192
  generated.pager = Paginator.new(
176
193
  per_page: config['per_page'],
@@ -199,13 +216,18 @@ class Model
199
216
  generated.data['autogen'] = 'jekyll-paginate-v2' if config['compatibility'] == 'v2'
200
217
 
201
218
  assign_generated_page_title!(generated, template, config, current_page, total_pages)
202
- assign_generated_page_permalink!(generated, template, config, current_page, total_pages)
203
-
204
- @add_item_lambda.call(
205
- generated,
206
- collection_index: collection_insertion_index_for_generated_item(generated, collection_replacement_state)
207
- )
208
- log("Emitted pagination page #{current_page}/#{total_pages} at '#{generated.url}' for template '#{Utils.relative_item_path(template)}'.", 'debug')
219
+ page_route_path = assign_generated_page_permalink!(generated, template, config, current_page, total_pages)
220
+ generated.data['pagination']['base'] = Utils.normalise_route(route_base)
221
+ generated.data['pagination']['path'] = Utils.join_route_fragments(route_path, page_route_path)
222
+
223
+ unless retaining_current_page
224
+ @add_item_lambda.call(
225
+ generated,
226
+ collection_index: collection_insertion_index_for_generated_item(generated, collection_replacement_state)
227
+ )
228
+ end
229
+ action = retaining_current_page ? 'Retained' : 'Emitted'
230
+ log("#{action} pagination page #{current_page}/#{total_pages} at '#{generated.url}' for template '#{Utils.relative_item_path(template)}'.", 'debug')
209
231
  new_pages << generated
210
232
  end
211
233
 
@@ -217,6 +239,32 @@ class Model
217
239
  }
218
240
  end
219
241
 
242
+ # Reuses an eligible source page/document while adding the small runtime
243
+ # interface and metadata normally supplied by generated subclasses.
244
+ def prepare_retained_page_one(item, current_page:, total_pages:)
245
+ item.extend(Pages::PagerSupport) unless item.respond_to?(:pager=)
246
+ item.data['pagination_info'] = {
247
+ 'curr_page' => current_page,
248
+ 'total_pages' => total_pages
249
+ }
250
+ item
251
+ end
252
+
253
+ # Clones mutable template presentation state before page one is updated in
254
+ # place, keeping all later page emission based on the original variant.
255
+ def clone_template_for_emission(template)
256
+ cloned_template = template.dup
257
+ # Keep the retained source independent from the snapshot used to emit its later pages.
258
+ cloned_data = Utils.deep_copy(Utils.safe_hash(template.data))
259
+ if cloned_template.respond_to?(:data=)
260
+ cloned_template.data = cloned_data
261
+ else
262
+ cloned_template.instance_variable_set(:@data, cloned_data)
263
+ end
264
+ cloned_template.content = template.content.to_s if cloned_template.respond_to?(:content=)
265
+ cloned_template
266
+ end
267
+
220
268
  # Builds insertion state for collection-template replacement.
221
269
  #
222
270
  # The state stores a moving insertion cursor so any generated
@@ -235,6 +283,21 @@ class Model
235
283
  }
236
284
  end
237
285
 
286
+ # Starts insertion immediately after a retained source document so later
287
+ # self-targeted indexes preserve collection ordering without remove/re-add.
288
+ def build_retained_collection_insertion_state(template)
289
+ return nil unless collection_template?(template)
290
+
291
+ collection_documents = template.collection.docs
292
+ template_index = collection_documents.index { |document| document.equal?(template) }
293
+ return nil if template_index.nil?
294
+
295
+ {
296
+ 'source_collection_label' => template.collection.label.to_s,
297
+ 'next_index' => template_index + 1
298
+ }
299
+ end
300
+
238
301
  # Returns the next insertion index for a generated document that
239
302
  # belongs in the same collection as the template it replaced.
240
303
  def collection_insertion_index_for_generated_item(generated, collection_replacement_state)
@@ -313,6 +376,16 @@ class Model
313
376
  Config::COLLECTION_TARGET_SHADOW
314
377
  end
315
378
 
379
+ # Determines whether page one can keep the source object's exact identity
380
+ # while honouring the effective output target.
381
+ def retain_source_template_as_page_one?(template, config)
382
+ target_mode = collection_target_mode_for_page(template, config, 1)
383
+ return target_mode == Config::COLLECTION_TARGET_SELF if template.is_a?(Jekyll::Document)
384
+ return target_mode == Config::COLLECTION_TARGET_PAGES if template.is_a?(Jekyll::Page)
385
+
386
+ false
387
+ end
388
+
316
389
  # Returns true when a template is a collection document.
317
390
  def collection_template?(template)
318
391
  template.is_a?(Jekyll::Document)