Add tag tooling: bake tags report and unlinked /tags/ pages

Adds Posts::TagIndex (counts, per-tag posts, per-tag/year breakdown)
and Posts::TagReport, wired into a new `bake tags` task that prints a
frequency table plus a tag-by-year sparkline straight to the terminal.

Also generates /tags/ and /tags/<tag>/ HTML pages via the existing
Posts plugin, listing tags with post counts and per-tag post listings.
Not linked from nav yet -- reachable by URL only until tags are
curated enough to surface properly.

Extracted PostListItemView out of YearPostsView so the post-link
rendering (incl. link posts) is shared with the new tag pages instead
of duplicated.
This commit is contained in:
Sami Samhuri 2026-06-21 20:39:41 -07:00
parent e9e664b462
commit e43bb0a71b
12 changed files with 329 additions and 21 deletions

View file

@ -278,6 +278,15 @@ def drafts
end
end
# Print tag post counts and a per-year sparkline of tag usage across posts/.
def tags
require "pressa/posts/repo"
require "pressa/posts/tag_report"
posts_by_year = Pressa::Posts::PostRepo.new.read_posts("posts")
puts Pressa::Posts::TagReport.from_posts_by_year(posts_by_year)
end
# Run StandardRB linter
def lint
run_standardrb

View file

@ -4,6 +4,7 @@ require "pressa/posts/writer"
require "pressa/posts/gemini_writer"
require "pressa/posts/json_feed"
require "pressa/posts/rss_feed"
require "pressa/posts/tag_index"
module Pressa
module Posts
@ -29,6 +30,8 @@ module Pressa
writer.write_posts_archive(target_path:)
writer.write_year_indexes(target_path:)
writer.write_month_rollups(target_path:)
writer.write_tags_index(target_path:)
writer.write_tag_pages(target_path:)
json_feed = JSONFeedWriter.new(site:, posts_by_year: @posts_by_year)
json_feed.write_feed(target_path:, limit: 30)

View file

@ -0,0 +1,44 @@
require "pressa/drafts"
module Pressa
module Posts
# Derives tag counts and per-tag/per-year breakdowns from a flat list of posts.
class TagIndex
def self.from_posts_by_year(posts_by_year)
new(posts_by_year.all_posts)
end
def initialize(posts)
@posts = posts
end
def tags
counts.keys
end
def counts
@counts ||= @posts.each_with_object(Hash.new(0)) do |post, tally|
post.tags.each { |tag| tally[tag] += 1 }
end.sort_by { |tag, count| [-count, tag] }.to_h
end
def posts_for(tag)
@posts.select { |post| post.tags.include?(tag) }.sort_by(&:date).reverse
end
def years
@posts.map(&:year).uniq.sort
end
def counts_by_tag_and_year
@counts_by_tag_and_year ||= @posts.each_with_object(Hash.new { |h, k| h[k] = Hash.new(0) }) do |post, tally|
post.tags.each { |tag| tally[tag][post.year] += 1 }
end
end
def slug(tag)
Drafts.slugify(tag)
end
end
end
end

View file

@ -0,0 +1,57 @@
require "pressa/posts/tag_index"
module Pressa
module Posts
# Renders TagIndex data as plain text for the terminal: a frequency table
# and a tag-by-year sparkline so trends are visible at a glance.
class TagReport
SPARK_CHARS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
def self.from_posts_by_year(posts_by_year)
new(TagIndex.from_posts_by_year(posts_by_year))
end
def initialize(tag_index)
@tag_index = tag_index
end
def to_s
"#{counts_table}\n\n#{sparkline_table}"
end
private
def counts_table
rows = @tag_index.counts.map { |tag, count| [tag, count.to_s] }
tag_width = ([3] + rows.map { |tag, _| tag.length }).max
lines = ["Tags by post count", "-" * 18]
rows.each { |tag, count| lines << "#{tag.ljust(tag_width)} #{count}" }
lines.join("\n")
end
def sparkline_table
years = @tag_index.years
tag_width = ([3] + @tag_index.tags.map(&:length)).max
lines = ["Tags over time (#{years.first}-#{years.last})", "-" * 30]
@tag_index.tags.each do |tag|
counts_by_year = @tag_index.counts_by_tag_and_year.fetch(tag, {})
lines << "#{tag.ljust(tag_width)} #{sparkline(counts_by_year, years)}"
end
lines.join("\n")
end
def sparkline(counts_by_year, years)
max = counts_by_year.values.max || 1
years.map do |year|
count = counts_by_year[year] || 0
next " " if count.zero?
index = ((count / max.to_f) * (SPARK_CHARS.size - 1)).round
SPARK_CHARS[index]
end.join
end
end
end
end

View file

@ -5,6 +5,9 @@ require "pressa/views/recent_posts_view"
require "pressa/views/archive_view"
require "pressa/views/year_posts_view"
require "pressa/views/month_posts_view"
require "pressa/views/tags_index_view"
require "pressa/views/tag_posts_view"
require "pressa/posts/tag_index"
module Pressa
module Posts
@ -65,8 +68,47 @@ module Pressa
end
end
def write_tags_index(target_path:)
content_view = Views::TagsIndexView.new(tag_index:, site: @site)
html = render_layout(
page_subtitle: "Tags",
canonical_url: @site.url_for("/tags/"),
content: content_view,
page_description: "Browse posts by tag"
)
file_path = File.join(target_path, "tags", "index.html")
Utils::FileWriter.write(path: file_path, content: html)
end
def write_tag_pages(target_path:)
tag_index.tags.each do |tag|
write_tag_page(tag:, target_path:)
end
end
private
def tag_index
@tag_index ||= Posts::TagIndex.from_posts_by_year(@posts_by_year)
end
def write_tag_page(tag:, target_path:)
content_view = Views::TagPostsView.new(tag:, posts: tag_index.posts_for(tag), site: @site)
slug = tag_index.slug(tag)
html = render_layout(
page_subtitle: "Tag: #{tag}",
canonical_url: @site.url_for("/tags/#{slug}/"),
content: content_view,
page_description: "Posts tagged #{tag}"
)
file_path = File.join(target_path, "tags", slug, "index.html")
Utils::FileWriter.write(path: file_path, content: html)
end
def write_post(post:, target_path:)
content_view = Views::PostView.new(post:, site: @site, article_class: "container")

View file

@ -0,0 +1,32 @@
require "phlex"
module Pressa
module Views
class PostListItemView < Phlex::HTML
def initialize(post:)
@post = post
end
def view_template
if @post.link_post?
li do
a(href: @post.link) { "#{@post.title}" }
time { short_date(@post.date) }
a(class: "permalink", href: @post.path) { "" }
end
else
li do
a(href: @post.path) { @post.title }
time { short_date(@post.date) }
end
end
end
private
def short_date(date)
date.strftime("%-d %b %Y")
end
end
end
end

View file

@ -0,0 +1,26 @@
require "phlex"
require "pressa/views/post_list_item_view"
module Pressa
module Views
class TagPostsView < Phlex::HTML
def initialize(tag:, posts:, site:)
@tag = tag
@posts = posts
@site = site
end
def view_template
div(class: "container") do
h1 { "Tag: #{@tag}" }
ul(class: "posts") do
@posts.each do |post|
render PostListItemView.new(post:)
end
end
end
end
end
end
end

View file

@ -0,0 +1,33 @@
require "phlex"
module Pressa
module Views
class TagsIndexView < Phlex::HTML
def initialize(tag_index:, site:)
@tag_index = tag_index
@site = site
end
def view_template
div(class: "container") do
h1 { "Tags" }
ul(class: "tags") do
@tag_index.counts.each do |tag, count|
li do
a(href: tag_path(tag)) { tag }
plain " (#{count})"
end
end
end
end
end
private
def tag_path(tag)
@site.url_for("/tags/#{@tag_index.slug(tag)}/")
end
end
end
end

View file

@ -1,4 +1,5 @@
require "phlex"
require "pressa/views/post_list_item_view"
module Pressa
module Views
@ -38,29 +39,10 @@ module Pressa
ul(class: "posts") do
month_posts.sorted_posts.each do |post|
render_post_item(post)
render PostListItemView.new(post:)
end
end
end
def render_post_item(post)
if post.link_post?
li do
a(href: post.link) { "#{post.title}" }
time { short_date(post.date) }
a(class: "permalink", href: post.path) { "" }
end
else
li do
a(href: post.path) { post.title }
time { short_date(post.date) }
end
end
end
def short_date(date)
date.strftime("%-d %b")
end
end
end
end

View file

@ -62,6 +62,7 @@ class Pressa::Posts::PluginTest < Minitest::Test
assert(File.exist?(File.join(target_path, "posts/2025/11/shredding/index.html")))
assert(File.exist?(File.join(target_path, "feed.json")))
assert(File.exist?(File.join(target_path, "feed.xml")))
assert(File.exist?(File.join(target_path, "tags/index.html")))
end
end
end

View file

@ -0,0 +1,52 @@
require "test_helper"
class Pressa::Posts::TagIndexTest < Minitest::Test
def post(slug:, year:, tags:)
Pressa::Posts::Post.new(
slug:,
title: slug,
author: "Sami Samhuri",
date: DateTime.parse("#{year}-06-01T10:00:00-07:00"),
formatted_date: "1st June, #{year}",
body: "<p>#{slug}</p>",
excerpt: "#{slug}...",
path: "/posts/#{year}/06/#{slug}",
tags:
)
end
def posts
[
post(slug: "a", year: 2020, tags: ["ruby", "rails"]),
post(slug: "b", year: 2020, tags: ["ruby"]),
post(slug: "c", year: 2022, tags: ["swift"])
]
end
def tag_index
@tag_index ||= Pressa::Posts::TagIndex.new(posts)
end
def test_counts_sorted_by_frequency_then_name
assert_equal({"ruby" => 2, "rails" => 1, "swift" => 1}, tag_index.counts)
end
def test_posts_for_returns_matching_posts_newest_first
assert_equal(["b", "a"], tag_index.posts_for("ruby").map(&:slug))
assert_equal(["c"], tag_index.posts_for("swift").map(&:slug))
end
def test_years_returns_sorted_unique_years
assert_equal([2020, 2022], tag_index.years)
end
def test_counts_by_tag_and_year_breaks_down_per_year
assert_equal({2020 => 2}, tag_index.counts_by_tag_and_year["ruby"])
assert_equal({2020 => 1}, tag_index.counts_by_tag_and_year["rails"])
assert_equal({2022 => 1}, tag_index.counts_by_tag_and_year["swift"])
end
def test_slug_uses_drafts_slugify
assert_equal("keyboard-shortcuts", tag_index.slug("keyboard shortcuts"))
end
end

View file

@ -33,7 +33,8 @@ class Pressa::Posts::PostWriterTest < Minitest::Test
formatted_date: "1st October, 2024",
body: "<p>regular body</p>",
excerpt: "regular body...",
path: "/posts/2024/10/regular-post"
path: "/posts/2024/10/regular-post",
tags: ["ruby", "rails"]
)
nov_posts = Pressa::Posts::MonthPosts.new(
@ -120,4 +121,30 @@ class Pressa::Posts::PostWriterTest < Minitest::Test
assert_includes(File.read(oct), "October 2024")
end
end
def test_write_tags_index_writes_tags_with_counts
Dir.mktmpdir do |dir|
writer.write_tags_index(target_path: dir)
index_path = File.join(dir, "tags/index.html")
assert(File.exist?(index_path))
html = File.read(index_path)
assert_includes(html, "https://samhuri.net/tags/ruby/")
assert_includes(html, "rails")
assert_includes(html, "(1)")
end
end
def test_write_tag_pages_writes_each_tag_page
Dir.mktmpdir do |dir|
writer.write_tag_pages(target_path: dir)
ruby_path = File.join(dir, "tags/ruby/index.html")
assert(File.exist?(ruby_path))
html = File.read(ruby_path)
assert_includes(html, "Tag: ruby")
assert_includes(html, "Regular")
refute_includes(html, "Linked")
end
end
end