diff --git a/bake.rb b/bake.rb index bc6b593..28eb6c3 100644 --- a/bake.rb +++ b/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 diff --git a/lib/pressa/posts/plugin.rb b/lib/pressa/posts/plugin.rb index 27f816a..b509d86 100644 --- a/lib/pressa/posts/plugin.rb +++ b/lib/pressa/posts/plugin.rb @@ -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) diff --git a/lib/pressa/posts/tag_index.rb b/lib/pressa/posts/tag_index.rb new file mode 100644 index 0000000..fc0efa4 --- /dev/null +++ b/lib/pressa/posts/tag_index.rb @@ -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 diff --git a/lib/pressa/posts/tag_report.rb b/lib/pressa/posts/tag_report.rb new file mode 100644 index 0000000..1960cd7 --- /dev/null +++ b/lib/pressa/posts/tag_report.rb @@ -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 diff --git a/lib/pressa/posts/writer.rb b/lib/pressa/posts/writer.rb index c40aeee..8b02e9b 100644 --- a/lib/pressa/posts/writer.rb +++ b/lib/pressa/posts/writer.rb @@ -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") diff --git a/lib/pressa/views/post_list_item_view.rb b/lib/pressa/views/post_list_item_view.rb new file mode 100644 index 0000000..68c1bf5 --- /dev/null +++ b/lib/pressa/views/post_list_item_view.rb @@ -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 diff --git a/lib/pressa/views/tag_posts_view.rb b/lib/pressa/views/tag_posts_view.rb new file mode 100644 index 0000000..32f7b71 --- /dev/null +++ b/lib/pressa/views/tag_posts_view.rb @@ -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 diff --git a/lib/pressa/views/tags_index_view.rb b/lib/pressa/views/tags_index_view.rb new file mode 100644 index 0000000..b381c29 --- /dev/null +++ b/lib/pressa/views/tags_index_view.rb @@ -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 diff --git a/lib/pressa/views/year_posts_view.rb b/lib/pressa/views/year_posts_view.rb index 9e03789..3384a84 100644 --- a/lib/pressa/views/year_posts_view.rb +++ b/lib/pressa/views/year_posts_view.rb @@ -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 diff --git a/test/posts/plugin_test.rb b/test/posts/plugin_test.rb index 8caf419..9dca92e 100644 --- a/test/posts/plugin_test.rb +++ b/test/posts/plugin_test.rb @@ -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 diff --git a/test/posts/tag_index_test.rb b/test/posts/tag_index_test.rb new file mode 100644 index 0000000..dc05395 --- /dev/null +++ b/test/posts/tag_index_test.rb @@ -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: "
#{slug}
", + 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 diff --git a/test/posts/writer_test.rb b/test/posts/writer_test.rb index 907cd41..1e2c935 100644 --- a/test/posts/writer_test.rb +++ b/test/posts/writer_test.rb @@ -33,7 +33,8 @@ class Pressa::Posts::PostWriterTest < Minitest::Test formatted_date: "1st October, 2024", body: "regular body
", 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