jekyll-better-related 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d33886e0039dce0880cdfe899eba88e942d7e6c757ff001bf5f80a3541f1dca2
4
+ data.tar.gz: 9fcb3ab8f7fbaa9392e37475538ce8420bf9449751144e4b9c863db1d59d4a36
5
+ SHA512:
6
+ metadata.gz: 8da0057c3d2569f6ed75b0e5702dee8439385b331244114e4aa8802fee0af0edd275123b8c07ba202722158dacf9233022bd6b6f07b70093719daf04f01e8217
7
+ data.tar.gz: 8978d32da133a5dcf9ba1f828ab4447c56f2127e47db817b25954ee76ba16d20c68794f34ab5ea7f73d311b732c747c1ddf49fb6d7be3481085a2c31c45268e4
data/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-08-20
6
+
7
+ ### Added
8
+ - Initial release
9
+ - Relevance scorer using tag overlap (3pts), category match (2pts), title keyword overlap (1pt), recency penalty (−0.1/month)
10
+ - Injects top N results into `page.related_posts` for every post
11
+ - Configurable `count`, `min_score`, and `cross_collection` options
12
+ - Per-post opt-out via `related_posts: false` frontmatter
13
+ - Per-post manual override via `related_posts: [url, ...]` frontmatter
14
+ - 19 tests passing
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jason Chance
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # jekyll-better-related
2
+
3
+ A Jekyll plugin that replaces Jekyll's useless built-in related posts (which just returns the most recent posts) with a proper relevance-scored algorithm.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/jekyll-better-related.svg)](https://rubygems.org/gems/jekyll-better-related)
6
+
7
+ ## Why?
8
+
9
+ Jekyll's `site.related_posts` is just the N most recently published posts — it has nothing to do with the current post's content. `jekyll-better-related` scores every candidate post on shared tags, categories, and title keywords, with a small recency penalty, so readers actually get posts that are relevant to what they're reading.
10
+
11
+ ## How It Scores
12
+
13
+ | Signal | Points |
14
+ |---|---|
15
+ | Shared tag | +3 per tag |
16
+ | Shared category | +2 |
17
+ | Shared title keyword | +1 per word |
18
+ | Age difference | −0.1 per 30 days |
19
+
20
+ Tags and categories are matched case-insensitively. Common English stop words are excluded from keyword scoring.
21
+
22
+ ## Installation
23
+
24
+ Add to your `Gemfile`:
25
+
26
+ ```ruby
27
+ gem "jekyll-better-related"
28
+ ```
29
+
30
+ Add to `plugins:` in `_config.yml`:
31
+
32
+ ```yaml
33
+ plugins:
34
+ - jekyll-better-related
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ All settings are optional. Defaults are shown:
40
+
41
+ ```yaml
42
+ better_related:
43
+ count: 5 # max related posts to surface
44
+ min_score: 1 # minimum relevance score; 0 disables threshold
45
+ cross_collection: false # include posts from other collections
46
+ ```
47
+
48
+ ## Template Usage
49
+
50
+ The plugin injects results into `page.related_posts` for every post. Use it in your layout:
51
+
52
+ ```liquid
53
+ {% if page.related_posts and page.related_posts.size > 0 %}
54
+ <h2>Related Posts</h2>
55
+ <ul>
56
+ {% for post in page.related_posts %}
57
+ <li><a href="{{ post.url }}">{{ post.title }}</a></li>
58
+ {% endfor %}
59
+ </ul>
60
+ {% endif %}
61
+ ```
62
+
63
+ `page.related_posts` is always an Array (may be empty), so checking `.size > 0` before rendering is safe.
64
+
65
+ ## Per-Post Frontmatter Options
66
+
67
+ ### Opt out entirely
68
+
69
+ ```yaml
70
+ related_posts: false
71
+ ```
72
+
73
+ ### Manual override
74
+
75
+ Supply an explicit list of post URLs (relative to site root). The plugin resolves them from the site and skips scoring:
76
+
77
+ ```yaml
78
+ related_posts:
79
+ - /2024/01/some-post/
80
+ - /2024/02/another-post/
81
+ ```
82
+
83
+ ## GitHub Pages / Safe Mode
84
+
85
+ This plugin uses a custom Generator and cannot run in GitHub Pages' safe mode. Use a CI/CD build step (GitHub Actions, Cloudflare Pages, etc.) to build and deploy.
86
+
87
+ ## License
88
+
89
+ [MIT](LICENSE.txt) © Jason Chance
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll"
4
+
5
+ module Jekyll
6
+ class BetterRelatedGenerator < Generator
7
+ safe true
8
+ priority :low
9
+
10
+ # Scoring weights
11
+ TAG_WEIGHT = 3
12
+ CATEGORY_WEIGHT = 2
13
+ KEYWORD_WEIGHT = 1
14
+ # Recency: subtract this many points per 30-day interval of separation
15
+ RECENCY_PENALTY = 0.1
16
+ SECONDS_PER_MONTH = 60 * 60 * 24 * 30
17
+
18
+ # Common English stop words to exclude from keyword matching
19
+ STOP_WORDS = %w[
20
+ a an the and or but in on at to for of with is are was were be been
21
+ this that it its by from as into about up out if so do does did
22
+ how what when where who which will would could should have has had
23
+ not no can more some than then their they them there these those
24
+ all any each just like may my our own said such use your
25
+ ].to_set.freeze
26
+
27
+ def generate(site)
28
+ @site = site
29
+ @config = site.config["better_related"] || {}
30
+
31
+ count = (@config["count"] || 3).to_i
32
+ min_score = (@config["min_score"] || 1).to_f
33
+
34
+ candidates = collect_candidates
35
+
36
+ site.posts.docs.each do |post|
37
+ # Per-post manual override: list of URLs
38
+ override = post.data["related_posts"]
39
+ if override.is_a?(Array)
40
+ post.data["related_posts"] = resolve_manual(override)
41
+ next
42
+ end
43
+
44
+ # Per-post opt-out
45
+ next if override == false
46
+
47
+ scored = candidates
48
+ .reject { |c| c.equal?(post) }
49
+ .map { |c| [c, score(post, c)] }
50
+ .select { |_, s| s >= min_score }
51
+ .sort_by { |_, s| -s }
52
+ .first(count)
53
+ .map(&:first)
54
+
55
+ post.data["related_posts"] = scored
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def collect_candidates
62
+ docs = @site.posts.docs.dup
63
+ if @config["cross_collection"]
64
+ @site.collections.each_value do |col|
65
+ next if col.label == "posts"
66
+ docs += col.docs
67
+ end
68
+ end
69
+ docs
70
+ end
71
+
72
+ def score(post, candidate)
73
+ s = 0.0
74
+
75
+ # Tag overlap
76
+ post_tags = Array(post.data["tags"]).map(&:downcase)
77
+ cand_tags = Array(candidate.data["tags"]).map(&:downcase)
78
+ s += (post_tags & cand_tags).size * TAG_WEIGHT
79
+
80
+ # Category match
81
+ post_cats = Array(post.data["categories"]).map(&:downcase)
82
+ cand_cats = Array(candidate.data["categories"]).map(&:downcase)
83
+ s += (post_cats & cand_cats).size * CATEGORY_WEIGHT
84
+
85
+ # Category singular field
86
+ if post.data["category"] && candidate.data["category"]
87
+ s += CATEGORY_WEIGHT if post.data["category"].downcase == candidate.data["category"].downcase
88
+ end
89
+
90
+ # Title keyword overlap
91
+ post_kw = keywords(post.data["title"].to_s)
92
+ cand_kw = keywords(candidate.data["title"].to_s)
93
+ s += (post_kw & cand_kw).size * KEYWORD_WEIGHT
94
+
95
+ # Recency penalty
96
+ post_date = post.data["date"]
97
+ cand_date = candidate.data["date"]
98
+ if post_date && cand_date
99
+ months_apart = (post_date.to_time - cand_date.to_time).abs / SECONDS_PER_MONTH
100
+ s -= months_apart * RECENCY_PENALTY
101
+ end
102
+
103
+ s
104
+ end
105
+
106
+ def keywords(title)
107
+ title.downcase
108
+ .gsub(/[^a-z0-9\s]/, " ")
109
+ .split
110
+ .reject { |w| STOP_WORDS.include?(w) || w.length < 3 }
111
+ .to_set
112
+ end
113
+
114
+ def resolve_manual(urls)
115
+ urls.filter_map do |url|
116
+ @site.posts.docs.find { |p| p.url == url }
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JekyllBetterRelated
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jekyll-better-related/version"
4
+ require "jekyll/better_related_generator"
metadata ADDED
@@ -0,0 +1,71 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jekyll-better-related
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jason Chance
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-08-21 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: jekyll
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.7'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '5.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.7'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '5.0'
32
+ description: jekyll-better-related replaces Jekyll's built-in related_posts (which
33
+ is just recency) with a proper relevance scorer using tag overlap, category match,
34
+ and title keyword overlap. Drop-in replacement — no theme changes required.
35
+ email:
36
+ - jason@jasonchance.com
37
+ executables: []
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - LICENSE.txt
43
+ - README.md
44
+ - lib/jekyll-better-related.rb
45
+ - lib/jekyll-better-related/version.rb
46
+ - lib/jekyll/better_related_generator.rb
47
+ homepage: https://github.com/jchance/jekyll-better-related
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/jchance/jekyll-better-related
52
+ source_code_uri: https://github.com/jchance/jekyll-better-related
53
+ changelog_uri: https://github.com/jchance/jekyll-better-related/blob/main/CHANGELOG.md
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 2.7.0
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 3.6.2
69
+ specification_version: 4
70
+ summary: Relevance-scored related posts for Jekyll — because the built-in is useless.
71
+ test_files: []