From 65e9734b8eb4a8b825a4a28f663046cd50e0508e Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Sun, 21 Jun 2026 20:10:22 -0700 Subject: [PATCH] Add dynamic drafts index page listing unpublished drafts --- lib/pressa/drafts/entry.rb | 13 ++++ lib/pressa/drafts/repo.rb | 48 +++++++++++++++ lib/pressa/drafts/writer.rb | 30 +++++++++ lib/pressa/site_generator.rb | 11 ++++ lib/pressa/views/drafts_view.rb | 37 +++++++++++ test/drafts/repo_test.rb | 89 +++++++++++++++++++++++++++ test/drafts/writer_test.rb | 38 ++++++++++++ test/site_generator_rendering_test.rb | 58 +++++++++++++++++ 8 files changed, 324 insertions(+) create mode 100644 lib/pressa/drafts/entry.rb create mode 100644 lib/pressa/drafts/repo.rb create mode 100644 lib/pressa/drafts/writer.rb create mode 100644 lib/pressa/views/drafts_view.rb create mode 100644 test/drafts/repo_test.rb create mode 100644 test/drafts/writer_test.rb diff --git a/lib/pressa/drafts/entry.rb b/lib/pressa/drafts/entry.rb new file mode 100644 index 0000000..32183fb --- /dev/null +++ b/lib/pressa/drafts/entry.rb @@ -0,0 +1,13 @@ +require "dry-struct" +require "pressa/site" + +module Pressa + class Drafts + class Entry < Dry::Struct + attribute :slug, Types::String + attribute :title, Types::String + attribute :timestamp, Types::Params::DateTime + attribute :path, Types::String + end + end +end diff --git a/lib/pressa/drafts/repo.rb b/lib/pressa/drafts/repo.rb new file mode 100644 index 0000000..a9533c1 --- /dev/null +++ b/lib/pressa/drafts/repo.rb @@ -0,0 +1,48 @@ +require "yaml" +require "date" +require "pressa/drafts" +require "pressa/drafts/entry" + +module Pressa + class Drafts + class Repo + def initialize(dir:) + @dir = dir + end + + def read_entries + Dir.glob(File.join(@dir, "*.md")).sort.filter_map { |file_path| read_entry(file_path) } + .sort_by(&:timestamp) + .reverse + end + + private + + def read_entry(file_path) + content = File.read(file_path) + return nil unless content =~ /\A---\s*\n(.*?)\n---\s*\n/m + + metadata = YAML.safe_load($1, permitted_classes: [Date, Time]) || {} + title = metadata["Title"] + timestamp_value = metadata["Timestamp"] + return nil unless title && timestamp_value + + slug = File.basename(file_path, ".md") + timestamp = parse_timestamp(timestamp_value) + + Entry.new(slug:, title:, timestamp:, path: "/drafts/#{slug}/") + end + + def parse_timestamp(value) + case value + when String + DateTime.parse(value) + when Integer + Time.at(value).to_datetime + else + value.to_datetime + end + end + end + end +end diff --git a/lib/pressa/drafts/writer.rb b/lib/pressa/drafts/writer.rb new file mode 100644 index 0000000..597b836 --- /dev/null +++ b/lib/pressa/drafts/writer.rb @@ -0,0 +1,30 @@ +require "pressa/utils/file_writer" +require "pressa/views/layout" +require "pressa/views/drafts_view" +require "pressa/drafts" + +module Pressa + class Drafts + class Writer + def initialize(site:, entries:) + @site = site + @entries = entries + end + + def write_index(target_path:) + content_view = Views::DraftsView.new(entries: @entries, site: @site) + + layout = Views::Layout.new( + site: @site, + page_subtitle: "Drafts", + canonical_url: @site.url_for("/drafts/"), + page_description: "Unpublished drafts", + content: content_view + ) + + file_path = File.join(target_path, "drafts", "index.html") + Utils::FileWriter.write(path: file_path, content: layout.call) + end + end + end +end diff --git a/lib/pressa/site_generator.rb b/lib/pressa/site_generator.rb index dbf9a64..61d03f8 100644 --- a/lib/pressa/site_generator.rb +++ b/lib/pressa/site_generator.rb @@ -1,5 +1,7 @@ require "fileutils" require "pressa/utils/file_writer" +require "pressa/drafts/repo" +require "pressa/drafts/writer" module Pressa class SiteGenerator @@ -23,10 +25,19 @@ module Pressa copy_static_files(source_path, target_path) process_public_directory(source_path, target_path) + write_drafts_index(source_path, target_path) if site.output_format == "html" end private + def write_drafts_index(source_path, target_path) + drafts_dir = File.join(source_path, "public", "drafts") + return unless Dir.exist?(drafts_dir) + + entries = Drafts::Repo.new(dir: drafts_dir).read_entries + Drafts::Writer.new(site:, entries:).write_index(target_path:) + end + def validate_paths!(source_path:, target_path:) source_abs = absolute_path(source_path) target_abs = absolute_path(target_path) diff --git a/lib/pressa/views/drafts_view.rb b/lib/pressa/views/drafts_view.rb new file mode 100644 index 0000000..1f7e539 --- /dev/null +++ b/lib/pressa/views/drafts_view.rb @@ -0,0 +1,37 @@ +require "phlex" + +module Pressa + module Views + class DraftsView < Phlex::HTML + def initialize(entries:, site:) + @entries = entries + @site = site + end + + def view_template + div(class: "container") do + h1 { "Drafts" } + + ul(class: "posts") do + @entries.each do |entry| + render_entry(entry) + end + end + end + end + + private + + def render_entry(entry) + li do + a(href: entry.path) { entry.title } + time { short_date(entry.timestamp) } + end + end + + def short_date(timestamp) + timestamp.strftime("%-d %b %Y") + end + end + end +end diff --git a/test/drafts/repo_test.rb b/test/drafts/repo_test.rb new file mode 100644 index 0000000..22fbbb4 --- /dev/null +++ b/test/drafts/repo_test.rb @@ -0,0 +1,89 @@ +require "test_helper" +require "fileutils" +require "tmpdir" + +class Pressa::Drafts::RepoTest < Minitest::Test + def test_read_entries_parses_title_and_timestamp + Dir.mktmpdir do |dir| + File.write(File.join(dir, "powder-day.md"), <<~MARKDOWN) + --- + Author: Shaun White + Title: Powder Day at Baker + Date: unpublished + Timestamp: 2025-11-05T10:00:00-08:00 + Tags: + --- + + TKTK + MARKDOWN + + entries = Pressa::Drafts::Repo.new(dir:).read_entries + + assert_equal(1, entries.length) + entry = entries.first + assert_equal("powder-day", entry.slug) + assert_equal("Powder Day at Baker", entry.title) + assert_equal("/drafts/powder-day/", entry.path) + assert_equal(2025, entry.timestamp.year) + end + end + + def test_read_entries_handles_legacy_unix_timestamps + Dir.mktmpdir do |dir| + File.write(File.join(dir, "old-draft.md"), <<~MARKDOWN) + --- + Author: Greg Graffin + Title: Old Draft + Date: unpublished + Timestamp: 1435424525 + --- + + TKTK + MARKDOWN + + entry = Pressa::Drafts::Repo.new(dir:).read_entries.first + + assert_equal(2015, entry.timestamp.year) + end + end + + def test_read_entries_sorts_newest_first + Dir.mktmpdir do |dir| + File.write(File.join(dir, "older.md"), <<~MARKDOWN) + --- + Author: Fat Mike + Title: Older Draft + Date: unpublished + Timestamp: 2024-01-01T00:00:00-08:00 + --- + + TKTK + MARKDOWN + + File.write(File.join(dir, "newer.md"), <<~MARKDOWN) + --- + Author: El Hefe + Title: Newer Draft + Date: unpublished + Timestamp: 2025-01-01T00:00:00-08:00 + --- + + TKTK + MARKDOWN + + entries = Pressa::Drafts::Repo.new(dir:).read_entries + + assert_equal(["Newer Draft", "Older Draft"], entries.map(&:title)) + end + end + + def test_read_entries_skips_files_without_front_matter + Dir.mktmpdir do |dir| + File.write(File.join(dir, "no-front-matter.md"), "# Just a heading\n") + + entries = Pressa::Drafts::Repo.new(dir:).read_entries + + assert_empty(entries) + end + end +end diff --git a/test/drafts/writer_test.rb b/test/drafts/writer_test.rb new file mode 100644 index 0000000..46de59d --- /dev/null +++ b/test/drafts/writer_test.rb @@ -0,0 +1,38 @@ +require "test_helper" +require "tmpdir" + +class Pressa::Drafts::WriterTest < Minitest::Test + def site + @site ||= Pressa::Site.new( + author: "Sami Samhuri", + email: "sami@samhuri.net", + title: "samhuri.net", + description: "blog", + url: "https://samhuri.net" + ) + end + + def entries + @entries ||= [ + Pressa::Drafts::Entry.new( + slug: "powder-day", + title: "Powder Day at Baker", + timestamp: DateTime.parse("2025-11-05T10:00:00-08:00"), + path: "/drafts/powder-day/" + ) + ] + end + + def test_write_index_writes_drafts_index_page + Dir.mktmpdir do |dir| + Pressa::Drafts::Writer.new(site:, entries:).write_index(target_path: dir) + + index_path = File.join(dir, "drafts", "index.html") + assert(File.exist?(index_path)) + html = File.read(index_path) + assert_includes(html, "Drafts") + assert_includes(html, "Powder Day at Baker") + assert_includes(html, "/drafts/powder-day/") + end + end +end diff --git a/test/site_generator_rendering_test.rb b/test/site_generator_rendering_test.rb index 3fcba23..1202fe2 100644 --- a/test/site_generator_rendering_test.rb +++ b/test/site_generator_rendering_test.rb @@ -176,6 +176,64 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test end end + def test_generate_writes_drafts_index_for_html_output + Dir.mktmpdir do |root| + source_path = File.join(root, "source") + target_path = File.join(root, "target") + drafts_dir = File.join(source_path, "public", "drafts") + FileUtils.mkdir_p(drafts_dir) + + File.write(File.join(drafts_dir, "powder-day.md"), <<~MARKDOWN) + --- + Author: Shaun White + Title: Powder Day at Baker + Date: unpublished + Timestamp: 2025-11-05T10:00:00-08:00 + --- + + TKTK + MARKDOWN + + plugin = PluginSpy.new + renderer = MarkdownRendererSpy.new + site = build_site(plugin:, renderer:) + + Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:) + + index_path = File.join(target_path, "drafts", "index.html") + assert(File.exist?(index_path)) + assert_includes(File.read(index_path), "Powder Day at Baker") + end + end + + def test_generate_skips_drafts_index_for_gemini_output + Dir.mktmpdir do |root| + source_path = File.join(root, "source") + target_path = File.join(root, "target") + drafts_dir = File.join(source_path, "public", "drafts") + FileUtils.mkdir_p(drafts_dir) + + File.write(File.join(drafts_dir, "powder-day.md"), <<~MARKDOWN) + --- + Author: Shaun White + Title: Powder Day at Baker + Date: unpublished + Timestamp: 2025-11-05T10:00:00-08:00 + --- + + TKTK + MARKDOWN + + plugin = PluginSpy.new + renderer = MarkdownRendererSpy.new + site = build_gemini_site(plugin:, renderer:) + + Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:) + + refute(File.exist?(File.join(target_path, "drafts", "index.html"))) + end + end + def test_generate_skips_tweets_directory_for_gemini_output Dir.mktmpdir do |root| source_path = File.join(root, "source")