jekyll-collection-pages 0.1.0 → 0.1.1

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: 97a284bb86cc0d06f068f04d67815db35d54294af4449a7070b29d4d5ddca255
4
- data.tar.gz: 35b687df7a07642a508ed417e26d1b32ba4c9627366370a9de711e786ba99b07
3
+ metadata.gz: 9fd1f1722ec16a60ac61dd1779dad45c8107720762e03c2e9ae623784d3696ce
4
+ data.tar.gz: 7ce8708d2ec0759f9ec478ec89691f102b6204da925cb25ff7374390f4aeee88
5
5
  SHA512:
6
- metadata.gz: 8481bd3ea5c40e2dec1f7a5b007a6735e9162e874f4a5ee3407454163036f6427e6d1f2f6137ede947c4b8cb8438b3365b64358baa05c90f36a5b0df60d953de
7
- data.tar.gz: a23a1f8ec7644c2151fdd36a159c2fe5b5adac1d673ccab2e79cf2b47968e96d7cc8a0ab0e82e6ea026556c00e2c82394fc8e8d4beeb1a827ca974f11f5f028f
6
+ metadata.gz: 9fed8faba1a44eb009b4a83d6c0844a1ef321faf1cdbd115a7997124080cfc112157147b4c0a07405372a048327084032f4abfef6b93865fd855a5f6bca4da0d
7
+ data.tar.gz: 45b2ef4c09b213f5c376472adb1acf1ad9be7811b1d5686cd3b78ef69b417400e2984c0c70809536d7ed572442e32d4e99460ec673c22966515d3c68a730d9d8
@@ -5,7 +5,7 @@
5
5
  // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6
6
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04",
7
7
  "features": {
8
- "ghcr.io/devcontainers/features/ruby:1": {
8
+ "ghcr.io/devcontainers/features/ruby:2": {
9
9
  "version": "3.3.4"
10
10
  }
11
11
  },
@@ -19,9 +19,17 @@
19
19
  35729
20
20
  ],
21
21
  // Use 'postCreateCommand' to run commands after the container is created.
22
- "postCreateCommand": ".devcontainer/post-create.sh"
22
+ "postCreateCommand": ".devcontainer/post-create.sh",
23
23
  // Configure tool-specific properties.
24
- // "customizations": {},
24
+ "customizations": {
25
+ "vscode": {
26
+ "settings": {
27
+ "rubyLsp.rubyVersionManager": {
28
+ "identifier": "none"
29
+ }
30
+ }
31
+ }
32
+ }
25
33
  // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
26
34
  // "remoteUser": "root"
27
35
  }
@@ -51,7 +51,7 @@ jobs:
51
51
  run: bundle install
52
52
 
53
53
  - name: Create Draft Pull Request
54
- uses: peter-evans/create-pull-request@v7
54
+ uses: peter-evans/create-pull-request@v8
55
55
  with:
56
56
  commit-message: "Bump version to ${{ needs.draft-release.outputs.tag_name }}"
57
57
  branch: release/draft
data/.gitignore CHANGED
@@ -19,3 +19,4 @@ _site/
19
19
  **/.jekyll-cache/
20
20
  Gemfile.lock
21
21
  .env
22
+ /.ruby-lsp/
data/.rubocop.yml CHANGED
@@ -20,14 +20,14 @@ Layout/LineLength:
20
20
  # Metrics cops
21
21
  # ----------------------------------------
22
22
  Metrics/AbcSize:
23
- Max: 23
23
+ Max: 24
24
24
  Metrics/BlockLength:
25
25
  Exclude:
26
26
  - 'spec/**/*'
27
27
  Metrics/ClassLength:
28
28
  Max: 240
29
29
  Metrics/CyclomaticComplexity:
30
- Max: 11
30
+ Max: 12
31
31
  Metrics/MethodLength:
32
32
  Max: 20
33
33
  Metrics/ModuleLength:
@@ -1,5 +1,2 @@
1
1
  {
2
- "rubyLsp.rubyVersionManager": {
3
- "identifier": "none"
4
- },
5
2
  }
data/Gemfile CHANGED
@@ -9,11 +9,13 @@ gem 'webrick', '~> 1.8'
9
9
 
10
10
  group :jekyll_plugins do
11
11
  gem 'jekyll-feed'
12
+ gem 'jekyll-octicons'
12
13
  gem 'jekyll-paginate'
13
14
  gem 'jekyll-redirect-from'
14
15
  gem 'jekyll-relative-links'
15
16
  gem 'jekyll-sitemap'
16
- gem 'jekyll-theme-profile' # needed for themeing
17
+ gem 'jekyll-theme-primerpages' # needed for themeing
18
+ gem 'jekyll-toc'
17
19
  end
18
20
 
19
21
  group :development do
data/README.md CHANGED
@@ -42,6 +42,7 @@ collection_pages:
42
42
  ```
43
43
 
44
44
  Key options:
45
+
45
46
  - `collection`: collection label (matches `collections` config).
46
47
  - `field`: front-matter key to group documents (string or list values).
47
48
  - `path`: base folder for generated pages (relative to site root).
@@ -72,7 +73,6 @@ Run `bundle exec jekyll serve` and visit the generated paths, e.g. `/docs/catego
72
73
  - `page.posts`: documents in that field bucket.
73
74
  - `page.paginator`: pagination data when `paginate` is set (same shape as Jekyll paginator).
74
75
 
75
-
76
76
  ```html
77
77
  {% raw %}
78
78
  ---
@@ -91,8 +91,8 @@ layout: default
91
91
 
92
92
  Every build populates a hash at `site.data.collection_pages[collection][field]` that contains:
93
93
 
94
- - `template` → the full sanitized template used for creating pages with placeholders intact. Directory-style values from `_config.yml` are auto-appended with `:field/page:num/index.html`. (e.g. `/docs/category/:field/page:num/index.html`)
95
- - `permalink` → the sanitized template for the index with placeholders intact (e.g. `/docs/category/:field/`)
94
+ - `template` → the sanitized template the generator used (`/docs/category/:field/index.html` for unpaginated configs, `/docs/category/:field/page:num/index.html` when `paginate` is positive).
95
+ - `permalink` → the sanitized template for the index with placeholders intact (`:num` never appears here because it always points to page 1, e.g. `/docs/category/:field/`)
96
96
  - `pages` → documents grouped by label (`{ label => [documents...] }`)
97
97
  - `labels`: metadata describing the generated index pages
98
98
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0
1
+ 0.1.1
data/demo/_config.yml CHANGED
@@ -1,4 +1,4 @@
1
- theme: jekyll-theme-profile
1
+ theme: jekyll-theme-primerpages
2
2
  style: topbar
3
3
  title: Jekyll Collection Pages plugin
4
4
  repository: PrimerPages/jekyll-collection-pages
@@ -75,7 +75,6 @@ relative_links:
75
75
  plugins:
76
76
  - jekyll-paginate
77
77
  - jekyll-collection-pages
78
- - jekyll-github-metadata
79
78
  - jekyll-octicons
80
79
  - jekyll-relative-links
81
80
  - jekyll-seo-tag
@@ -44,7 +44,7 @@ collection_pages:
44
44
 
45
45
  If you only need a single entry, `collection_pages` can also be a hash (the plugin normalises it internally). The array form keeps things consistent once you add more targets.
46
46
 
47
- Each `path` above is treated as a template—directory values automatically expand to `docs/<section>/categories/:field/page:num/index.html`. To switch to file-style permalinks, include the placeholders yourself, e.g. `docs/getting-started/categories/:field-page:num.html`.
47
+ Each `path` above is treated as a template—directory and can take `:field` and `:num` as placeholders. It is recommended to set only set the directory since values automatically expand to `<path>/:field/page:num/index.html`. The first page, and when pagination is off the generator will create pages in `<path>/:field/index.html`.
48
48
 
49
49
  ## Configuration options
50
50
 
@@ -66,13 +66,15 @@ Make sure every document you expect to index sets this field. When the field hol
66
66
 
67
67
  - Type: `String`
68
68
  - Required: ✔
69
- - Description: Path template relative to the site source. It must contain exactly one `:field` placeholder (replaced with the slugified field value) and one `:num` placeholder (replaced with the page number). When you omit placeholders in a directory-style path, the plugin automatically appends them as `<path>/:field/page:num/index.html`. When you provide a filename (ends in `.html`/`.htm`), you must include both placeholders yourself. Leading/trailing slashes are stripped either way.
69
+ - Description: Path template relative to the site source. It can also include a `:field` and a `:num` placeholder which are appended to the path if they are ommitted `:field/page:num/index.html` (recommended).
70
+ - `:field`: This placeholder is for the value of the field for the page.
71
+ - `:num`: This placeholder is for the page number of the pagination.
70
72
 
71
73
  Rules enforced by the generator:
72
74
 
73
- - `:field` must appear before `:num`, and they cannot be in the same path segment.
74
- - Paths ending in `.html`/`.htm` must include both placeholders already.
75
- - Leaving `path` blank defaults to `<collection>/:field/page:num/index.html`.
75
+ - When `:num` is present it must appear after `:field`, and the two placeholders cannot live in the same segment.
76
+ - Paths ending in `.html`/`.htm` must include the required placeholders explicitly.
77
+ - Leaving `path` blank defaults to `<collection>/:field/index.html` (or `<collection>/:field/page:num/index.html` when paginated).
76
78
 
77
79
  ### `layout`
78
80
 
@@ -94,8 +96,8 @@ Set `paginate` to `nil`, omit the key, or use a non-positive number to render a
94
96
 
95
97
  At build time the plugin exports `site.data.collection_pages[collection_name][field]`, which contains:
96
98
 
97
- - `template` → the full sanitized template used for creating pages with placeholders intact. Directory-style values from `_config.yml` are auto-appended with `:field/page:num/index.html`. (e.g. `/docs/category/:field/page:num/index.html`)
98
- - `permalink` → the sanitized template for the index with placeholders intact (e.g. `/docs/category/:field/`)
99
+ - `template` → the sanitized template used to create each index. Directory values inherit `<path>/:field/index.html` and add `/page:num/` only when pagination is enabled (e.g. `/docs/category/:field/index.html` or `/docs/category/:field/page:num/index.html`).
100
+ - `permalink` → the sanitized template for the index with placeholders intact (no `:num`, since it always points to page one, e.g. `/docs/category/:field/`)
99
101
  - `pages` → documents grouped by label (`{ label => [documents...] }`)
100
102
  - `labels`: metadata describing the generated index pages
101
103
 
@@ -16,14 +16,9 @@ collection_pages:
16
16
  path: blog/category
17
17
  layout: category_page.html
18
18
  paginate: 10
19
- - collection: posts
20
- field: category
21
- path: blog/category/:field-page:num.html
22
- layout: category_page.html
23
- paginate: 10
24
19
  ```
25
20
 
26
- The first entry produces folder-style pagination such as `/blog/category/reference/` and `/blog/category/reference/page2/`. The second entry demonstrates file-style pagination (e.g. `/blog/category/reference.html`, `/blog/category/reference-page2.html`). Both obey the same placeholder rules; pick the style that fits your permalink scheme.
21
+ This configuration uses folder-style pagination such as `/blog/category/reference/` and `/blog/category/reference/page2/`. Stick with this pattern for the most compatible URLs.
27
22
 
28
23
  Render a category listing with the exported data registry:
29
24
 
@@ -45,7 +45,7 @@ The structure is intentionally compatible with existing theme includes that expe
45
45
  `labels` exposes metadata about each generated index:
46
46
 
47
47
  - `index`: the first generated index page (`index.html`)
48
- - `pages`: array of all `TagIndexPage` objects
48
+ - `pages`: array of all generated `TagPage` objects
49
49
 
50
50
  Example:
51
51
 
@@ -41,8 +41,8 @@ Having trouble seeing generated pages or data? Start here.
41
41
 
42
42
  ## Path template errors
43
43
 
44
- - `path` values must resolve to exactly one `:field` placeholder and one `:num` placeholder. Directory-style paths (no `.html`/`.htm`) automatically become `<path>/:field/page:num/index.html`, but explicit filenames must include both placeholders.
45
- - `:field` must appear before `:num`, and they cannot live in the same path segment.
44
+ - `path` values must resolve to exactly one `:field` placeholder. Add a single `:num` placeholder only when the configuration sets a positive `paginate` value. Directory-style paths (no `.html`/`.htm`) automatically become `<path>/:field/index.html`, but Explicit filenames must include the required placeholders.
45
+ - Whenever `:num` is present it must appear after `:field`, and they cannot live in the same path segment.
46
46
  - When the build fails with an error referencing these placeholders, trim any trailing slashes and adjust the order. The generator logs the sanitized template in debug mode so you can confirm the final value.
47
47
 
48
48
  ## Liquid include expects `site.tags`
data/demo/index.md CHANGED
@@ -28,7 +28,7 @@ See how `jekyll-collection-pages` organizes technical documentation:
28
28
 
29
29
  See how `jekyll-collection-pages` can organize collections like posts:
30
30
 
31
- - [Tag directory](directory.md)
31
+ - [Generated pages directory](directory.md)
32
32
  - [Tag index](tags.md)
33
33
  - [Tag gallery](gallery.md)
34
34
 
@@ -52,7 +52,7 @@ Ready to use `jekyll-collection-pages` in your own project?
52
52
  {%- include post-card.html border="border-top" %}
53
53
  {%- endif %}
54
54
  {% endfor %}
55
- </div>>
55
+ </div>
56
56
 
57
57
 
58
58
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module CollectionPages
5
+ class CollectionConfig
6
+ attr_reader :collection_name, :tag_field, :tag_base_path, :tag_layout, :per_page
7
+
8
+ def initialize(config)
9
+ unless config.is_a?(Hash)
10
+ Jekyll.logger.error('CollectionPages:', "Invalid collection config entry: #{config.inspect}.")
11
+ raise ArgumentError, "Invalid collection_pages config entry. #{config.inspect}."
12
+ end
13
+ @collection_name = config['collection']
14
+ @tag_field = config['field']
15
+ @tag_base_path = config['path'] || @collection_name
16
+ @tag_layout = normalize_layout(config['layout'] || 'collection_layout')
17
+ @per_page = normalize_paginate_value(config['paginate'])
18
+
19
+ validate_config
20
+ end
21
+
22
+ private
23
+
24
+ def validate_config
25
+ missing_keys = []
26
+ missing_keys << 'collection' unless @collection_name
27
+ missing_keys << 'field' unless @tag_field
28
+ return if missing_keys.empty?
29
+
30
+ Jekyll.logger.error('CollectionPages:', "Missing required config keys: #{missing_keys.join(', ')}.")
31
+ raise ArgumentError, "Invalid collection_pages config entry. Missing: #{missing_keys.join(', ')}."
32
+ end
33
+
34
+ def normalize_paginate_value(value)
35
+ return nil if value.nil?
36
+
37
+ per_page = Integer(value)
38
+ return per_page if per_page.positive?
39
+
40
+ Jekyll.logger.warn('CollectionPages:',
41
+ "Non-positive paginate value #{value.inspect} for collection '#{@collection_name}' field '#{@tag_field}'. " \
42
+ 'Falling back to single page generation.')
43
+ nil
44
+ rescue ArgumentError, TypeError
45
+ raise ArgumentError,
46
+ "Invalid paginate value #{value.inspect} for collection '#{@collection_name}' field '#{@tag_field}'. Expected a numeric value."
47
+ end
48
+
49
+ def normalize_layout(layout)
50
+ layout.to_s.sub(/\.[^.]+\z/, '').sub(%r{\A_layouts/}, '')
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module CollectionPages
5
+ class CollectionPager
6
+ def initialize(site, config)
7
+ @site = site
8
+ @config = config
9
+ @tags_with_docs = sorted_tags(site, config.collection_name, config.tag_field)
10
+ path_template = PathTemplate.new(raw_template: config.tag_base_path, tag_field: config.tag_field, collection_name: config.collection_name,
11
+ require_num: !config.per_page.nil? && config.per_page.positive?)
12
+ @template = path_template.template
13
+ @permalink = TagPath.new(@template, ':field', slugify_value: false).url_for(1)
14
+ end
15
+
16
+ def create_pages
17
+ documents_map = {}
18
+ metadata_map = {}
19
+ @tags_with_docs.each do |tag, posts_with_tag|
20
+ page_count = TagPager.calculate_pages(posts_with_tag, @config.per_page)
21
+ tag_pages = []
22
+ (1..page_count).each do |page_num|
23
+ tag_page = build_page(@site, tag, posts_with_tag, page_num)
24
+ @site.pages << tag_page
25
+ tag_pages << tag_page
26
+ end
27
+ documents_map[tag] = posts_with_tag
28
+ metadata_map[tag] = {
29
+ 'pages' => tag_pages,
30
+ 'index' => tag_pages.first
31
+ }
32
+ Jekyll.logger.info('CollectionPages:',
33
+ "Generated #{tag_pages.size} page(s) for tag '#{tag}' in collection '#{@config.collection_name}'.")
34
+ end
35
+
36
+ set_metadata(documents_map, metadata_map)
37
+ end
38
+
39
+ private
40
+
41
+ def set_metadata(documents_map, metadata_map)
42
+ @site.data['collection_pages'] ||= {}
43
+ collection_registry = @site.data['collection_pages'][@config.collection_name] ||= {}
44
+ collection_registry[@config.tag_field] = {
45
+ 'template' => @template,
46
+ 'permalink' => @permalink,
47
+ 'labels' => metadata_map,
48
+ 'pages' => documents_map
49
+ }
50
+ end
51
+
52
+ def build_page(site, tag, posts_with_tag, page_num)
53
+ tag_path = TagPath.new(@template, tag)
54
+ paginator = TagPager.new(page_num, @config.per_page, posts_with_tag)
55
+
56
+ paginator_liquid = paginator.to_liquid.merge(
57
+ 'previous_page_path' => tag_path.url_for(paginator.previous_page),
58
+ 'next_page_path' => tag_path.url_for(paginator.next_page)
59
+ )
60
+ TagPage.new(
61
+ site,
62
+ {
63
+ dir: tag_path.dir_for(page_num),
64
+ name: tag_path.filename_for(page_num),
65
+ title: tag,
66
+ tag: tag_path.tag,
67
+ layout: @config.tag_layout,
68
+ posts: paginator.posts,
69
+ page_num: page_num,
70
+ paginator: @config.per_page ? paginator_liquid : nil
71
+ }
72
+ )
73
+ end
74
+
75
+ def sorted_tags(site, collection_name, tag_field)
76
+ tags = {}
77
+ collection = site.collections[collection_name]
78
+ return [] unless collection
79
+
80
+ Jekyll.logger.debug('CollectionPages:', "Found collection '#{collection_name}' with #{collection.docs.size} entries.")
81
+ collection.docs.each do |doc|
82
+ doc_tags = doc.data[tag_field]
83
+ next unless doc_tags
84
+
85
+ doc_tags = [doc_tags] if doc_tags.is_a?(String)
86
+ doc_tags.each do |tag|
87
+ tags[tag] ||= []
88
+ tags[tag] << doc
89
+ end
90
+ end
91
+ tags.keys.sort.map { |tag| [tag, tags[tag]] }
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module CollectionPages
5
+ INDEXFILE = 'index.html' unless const_defined?(:INDEXFILE)
6
+
7
+ class PathTemplate
8
+ attr_reader :tag_field, :collection_name
9
+
10
+ def initialize(raw_template:, tag_field:, collection_name:, require_num: true)
11
+ @tag_field = tag_field
12
+ @collection_name = collection_name
13
+ @require_num = require_num
14
+ @template = build_effective_template(raw_template)
15
+ end
16
+
17
+ def template
18
+ "/#{@template}"
19
+ end
20
+
21
+ private
22
+
23
+ def build_effective_template(raw)
24
+ sanitized = sanitize_path(raw)
25
+ sanitized = default_placeholder if sanitized.empty?
26
+ sanitized = add_field_placeholder(sanitized)
27
+ sanitized = add_num_placeholder(sanitized) if @require_num
28
+ sanitized = add_index(sanitized)
29
+ Jekyll.logger.debug('CollectionPages:', "Using path template '#{sanitized}' for collection '#{@collection_name}'.")
30
+ validate_template(sanitized)
31
+
32
+ sanitized
33
+ end
34
+
35
+ def sanitize_path(path)
36
+ path.to_s.strip.sub(%r{^/+}, '').sub(%r{/+\z}, '')
37
+ end
38
+
39
+ def default_placeholder
40
+ @collection_name.to_s
41
+ end
42
+
43
+ def add_field_placeholder(path)
44
+ return path if path.include?(':field')
45
+
46
+ raise ArgumentError, "Path template '#{path}' must include a ':field' placeholder." if path.end_with?('.html') || path.end_with?('.htm')
47
+
48
+ "#{path}/:field"
49
+ end
50
+
51
+ def add_num_placeholder(path)
52
+ return path if path.include?(':num')
53
+
54
+ raise ArgumentError, "Path template '#{path}' must include a ':num' placeholder." if path.end_with?('.html') || path.end_with?('.htm')
55
+
56
+ "#{path}/page:num"
57
+ end
58
+
59
+ def add_index(path)
60
+ return path if path.end_with?('.html') || path.end_with?('.htm')
61
+
62
+ "#{path}/#{INDEXFILE}"
63
+ end
64
+
65
+ def validate_template(path)
66
+ field_count = path.scan(':field').size
67
+ num_count = path.scan(':num').size
68
+
69
+ error_msg = ''
70
+ error_msg += "Path template '#{path}' must include exactly one ':field' placeholder. " if field_count != 1
71
+ error_msg += "Path template '#{path}' must include exactly one ':num' placeholder. " if @require_num && num_count != 1
72
+
73
+ if num_count.positive? && field_count.positive?
74
+ field_idx = path.index(':field')
75
+ num_idx = path.index(':num')
76
+ error_msg += "In path template '#{path}', ':field' must come before ':num'. " if num_idx < field_idx
77
+ end
78
+
79
+ segments = path.split('/').reject(&:empty?)
80
+ segments.each do |segment|
81
+ if segment.include?(':field') && segment.include?(':num')
82
+ error_msg += "In path template '#{path}', ':field' and ':num' cannot be in the same file segment. "
83
+ end
84
+ end
85
+
86
+ raise ArgumentError, error_msg unless error_msg.empty?
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module CollectionPages
5
+ class TagPage < PageWithoutAFile
6
+ def initialize(site, attributes)
7
+ dir = attributes[:dir]
8
+ name = attributes[:name]
9
+ tag = attributes[:tag]
10
+ title = attributes[:title]
11
+ layout = attributes[:layout]
12
+ posts = attributes[:posts]
13
+ page_num = attributes[:page_num]
14
+ paginator = attributes[:paginator]
15
+
16
+ # This sets up a page that has no source file on disk.
17
+ super(site, site.source, dir, name) # also calls process(name) internally
18
+
19
+ self.content = '' # virtual page body (optional)
20
+
21
+ self.data = {
22
+ 'layout' => layout, # layout NAME (relative path, no _layouts prefix)
23
+ 'tag' => tag.to_s,
24
+ 'title' => title.to_s,
25
+ 'posts' => posts,
26
+ 'page_num' => page_num
27
+ }
28
+ data['paginator'] = paginator if paginator
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module CollectionPages
5
+ class TagPager
6
+ attr_reader :page, :per_page, :posts, :total_posts, :total_pages,
7
+ :previous_page, :next_page
8
+
9
+ LIQUID_MAP = {
10
+ 'page' => :page, # the current page number
11
+ 'per_page' => :per_page, # the number of posts per page
12
+ 'posts' => :posts, # the paginated posts for this page
13
+ 'total_posts' => :total_posts, # the total number of posts being paginated
14
+ 'total_pages' => :total_pages, # the total number of pages
15
+ 'previous_page' => :previous_page, # the previous page number, or nil
16
+ 'next_page' => :next_page # the next page number, or nil
17
+ }.freeze
18
+
19
+ def self.calculate_pages(all_posts, per_page)
20
+ per_page_value = per_page.to_i
21
+ return 1 if per_page_value <= 0
22
+
23
+ (all_posts.size.to_f / per_page_value).ceil
24
+ end
25
+
26
+ def initialize(page_num, per_page, all_posts)
27
+ @page = page_num
28
+ @per_page = per_page.to_i.positive? ? per_page.to_i : 0
29
+ @total_posts = all_posts.size
30
+ @total_pages = self.class.calculate_pages(all_posts, @per_page)
31
+ @posts = slice_posts(all_posts)
32
+ @previous_page = previous_page_number
33
+ @next_page = next_page_number(total_pages)
34
+ end
35
+
36
+ def to_liquid
37
+ LIQUID_MAP.transform_values { |reader| public_send(reader) }
38
+ end
39
+
40
+ private
41
+
42
+ def slice_posts(all_posts)
43
+ return all_posts if @per_page <= 0
44
+
45
+ start_index = (@page - 1) * @per_page
46
+ all_posts.slice(start_index, @per_page) || []
47
+ end
48
+
49
+ def previous_page_number
50
+ @page > 1 ? @page - 1 : nil
51
+ end
52
+
53
+ def next_page_number(total_pages)
54
+ @page < total_pages ? @page + 1 : nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module CollectionPages
5
+ class TagPath
6
+ attr_reader :tag
7
+
8
+ def initialize(template, field_value, slugify_value: true)
9
+ @template = template
10
+ @tag = slugify_value ? Utils.slugify(field_value.to_s) : field_value.to_s
11
+ @segments = template.split('/').reject(&:empty?)
12
+ @explicit_file = file_segment?(@segments.last) && @segments.last.include?(':field')
13
+ end
14
+
15
+ def dir_for(page_number)
16
+ segments = @segments
17
+ segments = apply_field_value(segments)
18
+ segments = page_number == 1 ? remove_paginated_segments(segments) : apply_page_number(segments, page_number)
19
+ segments = drop_file_segment(segments)
20
+
21
+ File.join(*segments)
22
+ end
23
+
24
+ def filename_for(page_number)
25
+ segments = @segments.last(1)
26
+ segments = apply_field_value(segments)
27
+
28
+ if page_number == 1
29
+ return segments.join if @explicit_file
30
+
31
+ return INDEXFILE
32
+ end
33
+
34
+ segments = apply_page_number(segments, page_number)
35
+ segments.join
36
+ end
37
+
38
+ def url_for(page_number)
39
+ return nil if page_number.nil?
40
+
41
+ dir = dir_for(page_number)
42
+ filename = filename_for(page_number)
43
+ return formatted_index_path(dir) if filename == INDEXFILE
44
+
45
+ dir.empty? ? "/#{filename}" : "/#{dir}/#{filename}"
46
+ end
47
+
48
+ private
49
+
50
+ def apply_field_value(segments)
51
+ segments.map do |segment|
52
+ segment.include?(':field') ? segment.gsub(':field', @tag) : segment
53
+ end
54
+ end
55
+
56
+ def apply_page_number(segments, page_number)
57
+ segments.map do |segment|
58
+ segment.include?(':num') ? segment.gsub(':num', page_number.to_s) : segment
59
+ end
60
+ end
61
+
62
+ def drop_file_segment(segments)
63
+ if file_segment?(segments.last)
64
+ segments[0...-1]
65
+ else
66
+ segments
67
+ end
68
+ end
69
+
70
+ def file_segment?(segment)
71
+ segment && (segment.end_with?('.html') || segment.end_with?('.htm'))
72
+ end
73
+
74
+ def remove_paginated_segments(segments)
75
+ number_index = segments.index { |segment| segment.include?(':num') }
76
+ number_index ? segments[0...number_index] : segments
77
+ end
78
+
79
+ def formatted_index_path(dir)
80
+ return '/' if dir.empty?
81
+
82
+ "/#{dir}/"
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module CollectionPages
5
+ VERSION = File.read(File.expand_path('../../../VERSION', __dir__)).strip
6
+ end
7
+ end
@@ -1,165 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'collection_pages/collection_config'
4
+ require_relative 'collection_pages/collection_pager'
5
+ require_relative 'collection_pages/path_template'
6
+ require_relative 'collection_pages/tag_page'
7
+ require_relative 'collection_pages/tag_pager'
8
+ require_relative 'collection_pages/tag_path'
9
+ require_relative 'collection_pages/version'
10
+
3
11
  module Jekyll
4
12
  module CollectionPages
5
- INDEXFILE = 'index.html'
6
-
7
- class PathTemplate
8
- def initialize(raw_template:, tag_field:, collection_name:)
9
- @tag_field = tag_field
10
- @collection_name = collection_name
11
- @template = build_effective_template(raw_template)
12
- end
13
-
14
- def for_tag(tag_value)
15
- TagPathResolver.new(@template, tag_value)
16
- end
17
-
18
- def permalink
19
- TagPathResolver.new(@template, ':field', slugify_value: false).url_for(1)
20
- end
21
-
22
- def template
23
- "/#{@template}"
24
- end
25
-
26
- private
27
-
28
- def build_effective_template(raw)
29
- sanitized = sanitize_path(raw)
30
- sanitized = default_placeholder if sanitized.empty?
31
- sanitized = add_field_placeholder(sanitized)
32
- sanitized = add_num_placeholder(sanitized)
33
- sanitized = add_index(sanitized)
34
- Jekyll.logger.debug('CollectionPages:', "Using path template '#{sanitized}' for collection '#{@collection_name}'.")
35
- validate_template(sanitized)
36
-
37
- sanitized
38
- end
39
-
40
- def sanitize_path(path)
41
- path.to_s.strip.sub(%r{^/+}, '').sub(%r{/+\z}, '')
42
- end
43
-
44
- def default_placeholder
45
- @collection_name.to_s
46
- end
47
-
48
- def add_field_placeholder(path)
49
- return path if path.include?(':field')
50
-
51
- raise ArgumentError, "Path template '#{path}' must include a ':field' placeholder." if path.end_with?('.html') || path.end_with?('.htm')
52
-
53
- "#{path}/:field"
54
- end
55
-
56
- def add_num_placeholder(path)
57
- return path if path.include?(':num')
58
-
59
- raise ArgumentError, "Path template '#{path}' must include a ':num' placeholder." if path.end_with?('.html') || path.end_with?('.htm')
60
-
61
- "#{path}/page:num"
62
- end
63
-
64
- def add_index(path)
65
- return path if path.end_with?('.html') || path.end_with?('.htm')
66
-
67
- "#{path}/#{CollectionPages::INDEXFILE}"
68
- end
69
-
70
- def validate_template(path)
71
- field_count = path.scan(':field').size
72
- num_count = path.scan(':num').size
73
-
74
- error_msg = ''
75
- error_msg += "Path template '#{path}' must include exactly one ':field' placeholder. " if field_count != 1
76
- error_msg += "Path template '#{path}' must include exactly one ':num' placeholder. " if num_count != 1
77
-
78
- if num_count.positive? && field_count.positive?
79
- field_idx = path.index(':field')
80
- num_idx = path.index(':num')
81
- error_msg += "In path template '#{path}', ':field' must come before ':num'. " if num_idx < field_idx
82
- end
83
-
84
- segments = path.split('/').reject(&:empty?)
85
- segments.each do |segment|
86
- if segment.include?(':field') && segment.include?(':num')
87
- error_msg += "In path template '#{path}', ':field' and ':num' cannot be in the same file segment. "
88
- end
89
- end
90
-
91
- raise ArgumentError, error_msg unless error_msg.empty?
92
- end
93
-
94
- class TagPathResolver
95
- def initialize(template, field_value, slugify_value: true)
96
- @template = template
97
- @value = slugify_value ? Utils.slugify(field_value.to_s) : field_value.to_s
98
- @segments = template.split('/').reject(&:empty?)
99
- end
100
-
101
- def dir_for(page_number)
102
- segments = @segments
103
- segments = apply_field_value(segments)
104
- segments = page_number == 1 ? remove_paginated_segments(segments) : apply_page_number(segments, page_number)
105
- segments = drop_file_segment(segments)
106
-
107
- File.join(*segments)
108
- end
109
-
110
- def filename_for(page_number)
111
- return CollectionPages::INDEXFILE if page_number == 1
112
-
113
- segments = @segments.last(1)
114
- segments = apply_field_value(segments)
115
- segments = apply_page_number(segments, page_number)
116
- segments.join
117
- end
118
-
119
- def url_for(page_number)
120
- dir = dir_for(page_number)
121
- filename = filename_for(page_number)
122
- return formatted_index_path(dir) if filename == CollectionPages::INDEXFILE
123
-
124
- dir.empty? ? "/#{filename}" : "/#{dir}/#{filename}"
125
- end
126
-
127
- private
128
-
129
- def apply_field_value(segments)
130
- segments.map do |segment|
131
- segment.include?(':field') ? segment.gsub(':field', @value) : segment
132
- end
133
- end
134
-
135
- def apply_page_number(segments, page_number)
136
- segments.map do |segment|
137
- segment.include?(':num') ? segment.gsub(':num', page_number.to_s) : segment
138
- end
139
- end
140
-
141
- def drop_file_segment(segments)
142
- if segments.last.end_with?('.html') || segments.last.end_with?('.htm')
143
- segments[0...-1]
144
- else
145
- segments
146
- end
147
- end
148
-
149
- def remove_paginated_segments(segments)
150
- number_index = segments.index { |segment| segment.include?(':num') }
151
- number_index ? segments[0...number_index] : segments
152
- end
153
-
154
- def formatted_index_path(dir)
155
- return '/' if dir.empty?
156
-
157
- "/#{dir}/"
158
- end
159
- end
160
- end
161
-
162
- class TagPagination < Generator
13
+ class CollectionPages < Generator
163
14
  safe true
164
15
  priority :lowest
165
16
 
@@ -176,208 +27,19 @@ module Jekyll
176
27
  config.each do |collection_config|
177
28
  generate_for_config(site, collection_config)
178
29
  end
30
+ else
31
+ Jekyll.logger.error('CollectionPages:', 'Invalid configuration.')
179
32
  end
180
33
  Jekyll.logger.debug('CollectionPages:', "Generation complete. Total pages: #{site.pages.size}")
181
34
  end
182
35
 
183
36
  def generate_for_config(site, config)
184
- collection_name = config['collection']
185
- tag_field = config['field']
186
- tag_base_path = config['path']
187
- tag_layout = config['layout'] || 'collection_layout.html'
188
- per_page = normalize_paginate_value(config['paginate'], collection_name, tag_field)
189
- Jekyll.logger.debug('CollectionPages:', "Generating pages for collection: #{collection_name}::#{tag_field}")
190
-
191
- path_template = PathTemplate.new(raw_template: tag_base_path, tag_field: tag_field, collection_name: collection_name)
192
-
193
- site.data['collection_pages'] ||= {}
194
-
195
- documents_map, metadata_map = generate_paginated_tags(site, path_template, tag_layout, collection_name, tag_field, per_page)
196
-
197
- collection_registry = site.data['collection_pages'][collection_name] ||= {}
198
- collection_registry[tag_field] = {
199
- 'template' => path_template.template,
200
- 'permalink' => path_template.permalink,
201
- 'labels' => metadata_map,
202
- 'pages' => documents_map
203
- }
204
- end
205
-
206
- def sorted_tags(site, collection_name, tag_field)
207
- tags = {}
208
- collection = site.collections[collection_name]
209
- return [] unless collection
210
-
211
- Jekyll.logger.debug('CollectionPages:', "Found collection '#{collection_name}' with #{collection.docs.size} entries.")
212
- collection.docs.each do |doc|
213
- doc_tags = doc.data[tag_field]
214
- next unless doc_tags
215
-
216
- doc_tags = [doc_tags] if doc_tags.is_a?(String)
217
- doc_tags.each do |tag|
218
- tags[tag] ||= []
219
- tags[tag] << doc
220
- end
221
- end
222
- tags.keys.sort.map { |tag| [tag, tags[tag]] }
223
- end
224
-
225
- def generate_paginated_tags(site, path_template, tag_layout, collection_name, tag_field, per_page)
226
- tags_with_docs = sorted_tags(site, collection_name, tag_field)
227
-
228
- documents_map = {}
229
- metadata_map = {}
230
-
231
- tags_with_docs.each do |tag, posts_with_tag|
232
- tag_path = path_template.for_tag(tag)
233
-
234
- page_count = TagPager.calculate_pages(posts_with_tag, per_page)
235
- tag_pages = []
236
- (1..page_count).each do |page_num|
237
- paginator = TagPager.new(page_num, per_page, posts_with_tag, tag_path) if per_page
238
- posts_for_page = paginator ? paginator.posts : posts_with_tag
239
- page_dir = tag_path.dir_for(page_num)
240
- page_filename = tag_path.filename_for(page_num)
241
- tag_page = build_page(site, page_dir, page_filename, tag, tag_layout, posts_for_page, page_num, paginator)
242
- site.pages << tag_page
243
- tag_pages << tag_page
244
- end
245
-
246
- documents_map[tag] = posts_with_tag
247
- metadata_map[tag] = {
248
- 'pages' => tag_pages,
249
- 'index' => tag_pages.first
250
- }
251
- Jekyll.logger.info('CollectionPages:',
252
- "Generated #{tag_pages.size} page(s) for tag '#{tag}' in collection '#{collection_name}'.")
253
- end
254
-
255
- [documents_map, metadata_map]
256
- end
257
-
258
- def build_page(site, dir, page_filename, tag, layout, posts, page_num, paginator = nil)
259
- TagIndexPage.new(
260
- site,
261
- {
262
- dir: dir,
263
- name: page_filename,
264
- tag: tag,
265
- layout: layout,
266
- posts: posts,
267
- page_num: page_num,
268
- paginator: paginator
269
- }
270
- )
271
- end
272
-
273
- private
274
-
275
- def normalize_paginate_value(value, collection_name, tag_field)
276
- return nil if value.nil?
277
-
278
- per_page = Integer(value)
279
- return per_page if per_page.positive?
280
-
281
- Jekyll.logger.warn('CollectionPages:',
282
- "Non-positive paginate value #{value.inspect} for collection '#{collection_name}' field '#{tag_field}'. " \
283
- 'Falling back to single page generation.')
284
- nil
285
- rescue ArgumentError, TypeError
286
- raise ArgumentError,
287
- "Invalid paginate value #{value.inspect} for collection '#{collection_name}' field '#{tag_field}'. Expected a numeric value."
37
+ collection_config = CollectionConfig.new(config)
38
+ Jekyll.logger.debug('CollectionPages:',
39
+ "Generating pages for collection: #{collection_config.collection_name}::#{collection_config.tag_field}")
40
+ collection_pager = CollectionPager.new(site, collection_config)
41
+ collection_pager.create_pages
288
42
  end
289
43
  end
290
44
  end
291
-
292
- class TagIndexPage < PageWithoutAFile
293
- def initialize(site, attributes)
294
- dir = attributes[:dir]
295
- name = attributes[:name]
296
- tag = attributes[:tag]
297
- layout = attributes[:layout]
298
- posts = attributes[:posts]
299
- page_num = attributes[:page_num]
300
- paginator = attributes[:paginator]
301
-
302
- # This sets up a page that has no source file on disk.
303
- super(site, site.source, dir, name) # also calls process(name) internally
304
-
305
- self.content = '' # virtual page body (optional)
306
-
307
- self.data = {
308
- 'layout' => File.basename(layout, '.*'), # layout NAME (no path)
309
- 'tag' => tag,
310
- 'title' => tag.to_s,
311
- 'posts' => posts,
312
- 'page_num' => page_num
313
- }
314
- data['paginator'] = paginator if paginator
315
- end
316
- end
317
-
318
- class TagPager
319
- attr_reader :page, :per_page, :posts, :total_posts, :total_pages,
320
- :previous_page, :previous_page_path, :next_page, :next_page_path
321
-
322
- LIQUID_MAP = {
323
- 'page' => :page, # the current page number
324
- 'per_page' => :per_page, # the number of posts per page
325
- 'posts' => :posts, # the paginated posts for this page
326
- 'total_posts' => :total_posts, # the total number of posts being paginated
327
- 'total_pages' => :total_pages, # the total number of pages
328
- 'previous_page' => :previous_page, # the previous page number, or nil
329
- 'previous_page_path' => :previous_page_path, # the previous page path, or nil
330
- 'next_page' => :next_page, # the next page number, or nil
331
- 'next_page_path' => :next_page_path # the next page path, or nil
332
- }.freeze
333
-
334
- def self.calculate_pages(all_posts, per_page)
335
- per_page_value = per_page.to_i
336
- return 1 if per_page_value <= 0
337
-
338
- (all_posts.size.to_f / per_page_value).ceil
339
- end
340
-
341
- def initialize(page_num, per_page, all_posts, path_resolver)
342
- @page = page_num
343
- @per_page = per_page.to_i.positive? ? per_page.to_i : 0
344
- @total_posts = all_posts.size
345
- @total_pages = self.class.calculate_pages(all_posts, @per_page)
346
- @posts = slice_posts(all_posts)
347
- @path_resolver = path_resolver
348
- @previous_page = previous_page_number
349
- @next_page = next_page_number(total_pages)
350
- @previous_page_path = page_path(@previous_page)
351
- @next_page_path = page_path(@next_page)
352
- end
353
-
354
- def to_liquid
355
- LIQUID_MAP.transform_values { |reader| public_send(reader) }
356
- end
357
-
358
- private
359
-
360
- attr_reader :path_resolver
361
-
362
- def slice_posts(all_posts)
363
- return all_posts if @per_page <= 0
364
-
365
- start_index = (@page - 1) * @per_page
366
- all_posts.slice(start_index, @per_page) || []
367
- end
368
-
369
- def previous_page_number
370
- @page > 1 ? @page - 1 : nil
371
- end
372
-
373
- def next_page_number(total_pages)
374
- @page < total_pages ? @page + 1 : nil
375
- end
376
-
377
- def page_path(target_page)
378
- return unless target_page && target_page <= total_pages
379
-
380
- path_resolver.url_for(target_page)
381
- end
382
- end
383
45
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-collection-pages
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - allison@allisonthackston.com
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-11-18 00:00:00.000000000 Z
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jekyll
@@ -96,6 +96,13 @@ files:
96
96
  - jekyll-collection-pages.gemspec
97
97
  - lib/jekyll-collection-pages.rb
98
98
  - lib/jekyll/collection_pages.rb
99
+ - lib/jekyll/collection_pages/collection_config.rb
100
+ - lib/jekyll/collection_pages/collection_pager.rb
101
+ - lib/jekyll/collection_pages/path_template.rb
102
+ - lib/jekyll/collection_pages/tag_page.rb
103
+ - lib/jekyll/collection_pages/tag_pager.rb
104
+ - lib/jekyll/collection_pages/tag_path.rb
105
+ - lib/jekyll/collection_pages/version.rb
99
106
  homepage: https://github.com/PrimerPages/jekyll-collection-pages
100
107
  licenses:
101
108
  - MIT