mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-06-25 04:59:11 +00:00
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:
parent
e9e664b462
commit
e43bb0a71b
12 changed files with 329 additions and 21 deletions
9
bake.rb
9
bake.rb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
44
lib/pressa/posts/tag_index.rb
Normal file
44
lib/pressa/posts/tag_index.rb
Normal 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
|
||||
57
lib/pressa/posts/tag_report.rb
Normal file
57
lib/pressa/posts/tag_report.rb
Normal 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
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
32
lib/pressa/views/post_list_item_view.rb
Normal file
32
lib/pressa/views/post_list_item_view.rb
Normal 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
|
||||
26
lib/pressa/views/tag_posts_view.rb
Normal file
26
lib/pressa/views/tag_posts_view.rb
Normal 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
|
||||
33
lib/pressa/views/tags_index_view.rb
Normal file
33
lib/pressa/views/tags_index_view.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
52
test/posts/tag_index_test.rb
Normal file
52
test/posts/tag_index_test.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue