jekyll-stats 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebf4d6ad1019af942a6644fe37472e28472ccbbe2ac58f3ba6503bf134f563e0
4
- data.tar.gz: e7c6050c13373f795eca54ed88573ee3c46df3a91b6994839e74e4b53dd869fd
3
+ metadata.gz: 3465b65818c34b7a72b5b76d9bb242a37487837e5639927ae65c7d9a00b9ac62
4
+ data.tar.gz: 9e408de4fcfb55f1581a2b1905018852095cd4ae5c9a4a2f7592e252e24e42b0
5
5
  SHA512:
6
- metadata.gz: 81fbfb5befa5de983e88c396c97c45f5271e3b42bf17388ea86345e68349f71d8aff0e02f01900827c833b61264620cd15ebcb88b9beadfd01caf239a6825e67
7
- data.tar.gz: e833ddc5568285c114f41f75efc29aeb497ff0cfa35388405f80b6b6418b270756f17a8a8be87fe2c5305f7695b0cceb8783a7605e5cf762d8ce0b34a0e6fe92
6
+ metadata.gz: bd03f85ad69f529b003a211a7004acdf0a30851f0a2084abab6f3f142a511a620d7c3d4f755f4dcab6abc2286c55bb5f25c7bec610ccf565e0f012e975ba21d7
7
+ data.tar.gz: d42e6b8897070b6529b1cc32f7e6e52c11563e2890bcfdd5c03f4a2237216cb2afb80c62d2ec94489fe6433ffb3706e954d308e31c72a1b4f67fa7a486716ab1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-08-30
4
+
5
+ - Add `internal_links` to stats: posts ranked by inbound links from other posts
6
+ - Source posts can be excluded by tag via `jekyll-stats.link_source_exclude_tags` in `_config.yml`
7
+ - `--json` no longer prints "Loading site..." to stdout, so output pipes cleanly to `jq`
8
+
9
+ ## [0.2.0] - 2026-01-03
10
+
11
+ - Add `--tags` / `-t` option to filter stats by tags
12
+
3
13
  ## [0.1.0] - 2025-12-21
4
14
 
5
15
  - Initial release
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  A Jekyll plugin that adds a `jekyll stats` command to display site statistics.
4
4
 
5
+ [rubygems.org/gems/jekyll-stats](https://rubygems.org/gems/jekyll-stats)
6
+
5
7
  ## Installation
6
8
 
7
9
  Add to your Jekyll site's Gemfile:
@@ -28,6 +30,10 @@ jekyll stats --json
28
30
 
29
31
  # Include drafts in calculations
30
32
  jekyll stats --drafts
33
+
34
+ # Filter by tags (comma-separated, matches posts with ANY of the tags)
35
+ jekyll stats --tags ruby
36
+ jekyll stats -t ruby,rails
31
37
  ```
32
38
 
33
39
  ### Terminal Output
@@ -75,10 +81,27 @@ The `--save` flag writes `_data/stats.json` with this structure:
75
81
  "posts_by_day_of_week": { "monday": 23, "tuesday": 18, "wednesday": 15, "thursday": 20, "friday": 22, "saturday": 14, "sunday": 15 },
76
82
  "tags": [{ "name": "ruby", "count": 34 }, { "name": "opensource", "count": 28 }],
77
83
  "categories": [{ "name": "code", "count": 45 }, { "name": "cars", "count": 32 }],
84
+ "internal_links": [
85
+ { "url": "/2024/01/foo", "title": "Foo", "inbound_count": 7, "inbound_from": ["/2024/03/bar", "/2024/06/baz"] }
86
+ ],
78
87
  "drafts_count": 3
79
88
  }
80
89
  ```
81
90
 
91
+ ### Internal links
92
+
93
+ `internal_links` lists posts ranked by how many other posts link to them. It matches markdown and `<a href>` links that point at another post's URL, either as a root-relative path or an absolute URL under your site's `url:`. Self-links and duplicate links from the same source post are ignored.
94
+
95
+ To stop link-list style posts (year in review, roundups) inflating the counts, exclude them as sources by tag in `_config.yml`:
96
+
97
+ ```yaml
98
+ jekyll-stats:
99
+ link_source_exclude_tags:
100
+ - roundup
101
+ ```
102
+
103
+ Excluded posts still appear as targets; they just don't contribute outbound links.
104
+
82
105
  ## Building a Stats Page
83
106
 
84
107
  After running `jekyll stats --save`, create a stats page using Liquid:
@@ -89,50 +112,101 @@ layout: page
89
112
  title: Site Statistics
90
113
  ---
91
114
 
92
- <p>
93
- <strong>{{ site.data.stats.total_posts }}</strong> posts,
94
- <strong>{{ site.data.stats.total_words | divided_by: 1000 }}k</strong> words,
95
- <strong>{{ site.data.stats.reading_minutes | divided_by: 60 }}</strong> hours of reading.
96
- </p>
115
+ <p>{{ site.data.stats.total_posts }} posts, {{ site.data.stats.total_words }} words, ~{{ site.data.stats.reading_minutes }} minutes reading time.</p>
116
+
117
+ <p>Average {{ site.data.stats.average_words }} words per post. Longest: <a href="{{ site.data.stats.longest_post.url }}">{{ site.data.stats.longest_post.title }}</a> ({{ site.data.stats.longest_post.words }} words).</p>
97
118
 
98
- <p>
99
- Writing since {{ site.data.stats.first_post.date }} ({{ site.data.stats.years_active }} years).
100
- Averaging {{ site.data.stats.posts_per_month }} posts per month.
101
- </p>
119
+ <p>First post: {{ site.data.stats.first_post.date }} | Last post: {{ site.data.stats.last_post.date }} | {{ site.data.stats.years_active }} years active.</p>
102
120
 
103
121
  <h2>Posts by Year</h2>
104
- <ul>
122
+ <table>
123
+ <thead>
124
+ <tr>
125
+ <th style="text-align: left">Year</th>
126
+ <th style="text-align: right">Posts</th>
127
+ </tr>
128
+ </thead>
129
+ <tbody>
105
130
  {% for year in site.data.stats.posts_by_year %}
106
- <li>{{ year.year }}: {{ year.count }} posts</li>
131
+ <tr>
132
+ <td>{{ year.year }}</td>
133
+ <td style="text-align: right">{{ year.count }}</td>
134
+ </tr>
107
135
  {% endfor %}
108
- </ul>
136
+ </tbody>
137
+ </table>
109
138
 
110
139
  <h2>Top Tags</h2>
111
- <ul>
140
+ <table>
141
+ <thead>
142
+ <tr>
143
+ <th style="text-align: left">Tag</th>
144
+ <th style="text-align: right">Posts</th>
145
+ </tr>
146
+ </thead>
147
+ <tbody>
112
148
  {% for tag in site.data.stats.tags limit:10 %}
113
- <li>{{ tag.name }} ({{ tag.count }})</li>
149
+ <tr>
150
+ <td>{{ tag.name }}</td>
151
+ <td style="text-align: right">{{ tag.count }}</td>
152
+ </tr>
114
153
  {% endfor %}
115
- </ul>
116
-
117
- <h2>Extremes</h2>
118
- <ul>
119
- <li>Longest: <a href="{{ site.data.stats.longest_post.url }}">{{ site.data.stats.longest_post.title }}</a> ({{ site.data.stats.longest_post.words }} words)</li>
120
- <li>Shortest: <a href="{{ site.data.stats.shortest_post.url }}">{{ site.data.stats.shortest_post.title }}</a> ({{ site.data.stats.shortest_post.words }} words)</li>
121
- </ul>
122
-
123
- <h2>Day of Week</h2>
124
- <p>
125
- I write most on
126
- {% assign max_day = "" %}
127
- {% assign max_count = 0 %}
128
- {% for day in site.data.stats.posts_by_day_of_week %}
129
- {% if day[1] > max_count %}
130
- {% assign max_day = day[0] %}
131
- {% assign max_count = day[1] %}
132
- {% endif %}
133
- {% endfor %}
134
- {{ max_day | capitalize }}s ({{ max_count }} posts).
135
- </p>
154
+ </tbody>
155
+ </table>
156
+
157
+ <p><small>Generated {{ site.data.stats.generated_at | date: "%b %d, %Y" }}</small></p>
158
+ ```
159
+
160
+ ## Automating Stats Updates
161
+
162
+ You can regenerate stats automatically before each build.
163
+
164
+ ### Rake Task
165
+
166
+ ```ruby
167
+ # Rakefile
168
+ task :build do
169
+ sh "jekyll stats --save"
170
+ sh "jekyll build"
171
+ end
172
+ ```
173
+
174
+ ### GitHub Actions
175
+
176
+ ```yaml
177
+ # .github/workflows/deploy.yml
178
+ jobs:
179
+ build:
180
+ runs-on: ubuntu-latest
181
+ steps:
182
+ - uses: actions/checkout@v4
183
+ - uses: ruby/setup-ruby@v1
184
+ with:
185
+ ruby-version: '3.3'
186
+ bundler-cache: true
187
+
188
+ - name: Generate stats
189
+ run: bundle exec jekyll stats --save
190
+
191
+ - name: Commit stats
192
+ run: |
193
+ git config user.name "github-actions[bot]"
194
+ git config user.email "github-actions[bot]@users.noreply.github.com"
195
+ git add _data/stats.json
196
+ git diff --staged --quiet || git commit -m "Update site stats"
197
+ git push
198
+
199
+ - name: Build site
200
+ run: bundle exec jekyll build
201
+ ```
202
+
203
+ ### Git Pre-commit Hook
204
+
205
+ ```bash
206
+ #!/bin/sh
207
+ # .git/hooks/pre-commit
208
+ bundle exec jekyll stats --save
209
+ git add _data/stats.json
136
210
  ```
137
211
 
138
212
  ## Development
@@ -13,6 +13,7 @@ module JekyllStats
13
13
  c.option "save", "--save", "Save stats to _data/stats.json"
14
14
  c.option "json", "--json", "Output raw JSON to stdout"
15
15
  c.option "drafts", "-D", "--drafts", "Include drafts in calculations"
16
+ c.option "tags", "-t", "--tags TAGS", Array, "Filter stats by tags (comma-separated)"
16
17
  c.option "config", "--config CONFIG_FILE[,CONFIG_FILE2,...]", Array, "Custom configuration file"
17
18
  c.option "source", "-s", "--source SOURCE", "Custom source directory"
18
19
  c.option "destination", "-d", "--destination DESTINATION", "Custom destination directory"
@@ -24,6 +25,7 @@ module JekyllStats
24
25
  end
25
26
 
26
27
  def process(options)
28
+ Jekyll.logger.log_level = :error if options["json"]
27
29
  options = configuration_from_options(options)
28
30
  site = Jekyll::Site.new(options)
29
31
 
@@ -31,7 +33,7 @@ module JekyllStats
31
33
  site.reset
32
34
  site.read
33
35
 
34
- calculator = StatsCalculator.new(site, include_drafts: options["drafts"])
36
+ calculator = StatsCalculator.new(site, include_drafts: options["drafts"], filter_tags: options["tags"])
35
37
  stats = calculator.calculate
36
38
 
37
39
  if options["json"]
@@ -34,6 +34,11 @@ module JekyllStats
34
34
  lines << categories_line
35
35
  end
36
36
 
37
+ if stats[:internal_links].any?
38
+ lines << ""
39
+ lines << most_linked_posts
40
+ end
41
+
37
42
  if stats[:drafts_count].positive?
38
43
  lines << ""
39
44
  lines << "Drafts: #{stats[:drafts_count]}"
@@ -99,6 +104,15 @@ module JekyllStats
99
104
  "Categories:\n #{cat_strs.join(" | ")}"
100
105
  end
101
106
 
107
+ def most_linked_posts
108
+ links = stats[:internal_links].first(5)
109
+ lines = ["Most Linked Posts:"]
110
+ links.each do |l|
111
+ lines << " #{l[:inbound_count].to_s.rjust(2)} #{truncate(l[:title], 50)}"
112
+ end
113
+ lines.join("\n")
114
+ end
115
+
102
116
  def format_number(n)
103
117
  n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
104
118
  end
@@ -4,11 +4,12 @@ module JekyllStats
4
4
  class StatsCalculator
5
5
  WORDS_PER_MINUTE = 200
6
6
 
7
- attr_reader :site, :include_drafts
7
+ attr_reader :site, :include_drafts, :filter_tags
8
8
 
9
- def initialize(site, include_drafts: false)
9
+ def initialize(site, include_drafts: false, filter_tags: nil)
10
10
  @site = site
11
11
  @include_drafts = include_drafts
12
+ @filter_tags = normalize_filter_tags(filter_tags)
12
13
  end
13
14
 
14
15
  def calculate
@@ -39,6 +40,7 @@ module JekyllStats
39
40
  posts_by_day_of_week: posts_by_day_of_week(posts),
40
41
  tags: tag_counts(posts),
41
42
  categories: category_counts(posts),
43
+ internal_links: internal_links(posts),
42
44
  drafts_count: drafts_count
43
45
  }
44
46
  end
@@ -46,9 +48,23 @@ module JekyllStats
46
48
  def collect_posts
47
49
  posts = site.posts.docs.dup
48
50
  posts += site.drafts if include_drafts && site.respond_to?(:drafts)
51
+ posts = filter_posts_by_tags(posts) if filter_tags
49
52
  posts
50
53
  end
51
54
 
55
+ def filter_posts_by_tags(posts)
56
+ posts.select do |post|
57
+ post_tags = (post.data["tags"] || []).map { |t| normalize_tag(t) }
58
+ (filter_tags & post_tags).any?
59
+ end
60
+ end
61
+
62
+ def normalize_filter_tags(tags)
63
+ return nil if tags.nil? || tags.empty?
64
+
65
+ tags.map { |t| normalize_tag(t) }
66
+ end
67
+
52
68
  def word_count(post)
53
69
  content = post.content.to_s
54
70
  text = content.gsub(/<[^>]*>/, " ")
@@ -112,22 +128,62 @@ module JekyllStats
112
128
  counts = Hash.new(0)
113
129
  posts.each do |post|
114
130
  tags = post.data["tags"] || []
115
- tags.each { |tag| counts[tag] += 1 }
131
+ tags.each { |tag| counts[normalize_tag(tag)] += 1 }
116
132
  end
117
133
  counts.sort_by { |_, count| -count }
118
134
  .map { |name, count| { name: name, count: count } }
119
135
  end
120
136
 
137
+ def normalize_tag(tag)
138
+ tag.to_s.strip.gsub(/[,;:]+\z/, "").strip
139
+ end
140
+
121
141
  def category_counts(posts)
122
142
  counts = Hash.new(0)
123
143
  posts.each do |post|
124
144
  categories = post.data["categories"] || []
125
- categories.each { |cat| counts[cat] += 1 }
145
+ categories.each { |cat| counts[normalize_tag(cat)] += 1 }
126
146
  end
127
147
  counts.sort_by { |_, count| -count }
128
148
  .map { |name, count| { name: name, count: count } }
129
149
  end
130
150
 
151
+ def internal_links(posts)
152
+ by_url = posts.each_with_object({}) { |p, h| h[normalize_url(p.url)] = p }
153
+ exclude = Array(site.config.dig("jekyll-stats", "link_source_exclude_tags")).map { |t| normalize_tag(t) }
154
+ site_url = site.config["url"].to_s
155
+
156
+ inbound = Hash.new { |h, k| h[k] = [] }
157
+ posts.each do |post|
158
+ post_tags = (post.data["tags"] || []).map { |t| normalize_tag(t) }
159
+ next if exclude.any? && (exclude & post_tags).any?
160
+
161
+ post.content.to_s.scan(%r{(?:\]\(|<a\s[^>]*href=["'])([^"'\s)]+)}i).flatten.each do |href|
162
+ href = href.delete_prefix(site_url) unless site_url.empty?
163
+ next unless href.start_with?("/")
164
+
165
+ target = by_url[normalize_url(href)]
166
+ next unless target
167
+ next if target == post
168
+
169
+ inbound[target] << post
170
+ end
171
+ end
172
+
173
+ inbound.map { |target, sources|
174
+ {
175
+ url: target.url,
176
+ title: target.data["title"] || "(untitled)",
177
+ inbound_count: sources.uniq.size,
178
+ inbound_from: sources.uniq.map(&:url).sort
179
+ }
180
+ }.sort_by { |r| [-r[:inbound_count], r[:url]] }
181
+ end
182
+
183
+ def normalize_url(url)
184
+ url.sub(/[#?].*\z/, "").sub(%r{/index\.html\z}, "").chomp(".html").chomp("/")
185
+ end
186
+
131
187
  def drafts_count
132
188
  return 0 unless site.respond_to?(:drafts) && site.drafts
133
189
 
@@ -152,6 +208,7 @@ module JekyllStats
152
208
  posts_by_day_of_week: %w[sunday monday tuesday wednesday thursday friday saturday].each_with_object({}) { |d, h| h[d] = 0 },
153
209
  tags: [],
154
210
  categories: [],
211
+ internal_links: [],
155
212
  drafts_count: 0
156
213
  }
157
214
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JekyllStats
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jekyll-stats
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '4.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: logger
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
26
40
  description: Adds a 'jekyll stats' command that computes and displays site statistics
27
41
  including post counts, word counts, reading times, tag/category distributions, and
28
42
  posting frequency.
@@ -63,7 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
63
77
  - !ruby/object:Gem::Version
64
78
  version: '0'
65
79
  requirements: []
66
- rubygems_version: 4.0.1
80
+ rubygems_version: 4.0.17
67
81
  specification_version: 4
68
82
  summary: Jekyll plugin that generates site statistics
69
83
  test_files: []