diff --git a/lib/pressa/posts/gemini_writer.rb b/lib/pressa/posts/gemini_writer.rb index bb0e9ad..213b579 100644 --- a/lib/pressa/posts/gemini_writer.rb +++ b/lib/pressa/posts/gemini_writer.rb @@ -23,7 +23,7 @@ module Pressa rows << "=> #{link.href}" end rows << "" unless home_links.empty? - rows << "Recent posts" + rows << "## Recent posts" rows << "" @posts_by_year.recent_posts(limit).each do |post| @@ -31,7 +31,6 @@ module Pressa end rows << "" - rows << "=> /posts/ Archive" rows << "=> #{web_url_for("/")} Website" rows << "" @@ -39,11 +38,11 @@ module Pressa Utils::FileWriter.write(path: file_path, content: rows.join("\n")) end - def write_archive(target_path:) - rows = ["# Archive", ""] + def write_posts_index(target_path:) + rows = ["# #{@site.title} posts", "## Feed", ""] - @posts_by_year.sorted_years.each do |year| - rows << "=> /posts/#{year}/ #{year}" + @posts_by_year.all_posts.each do |post| + rows.concat(post_listing_lines(post)) end rows << "" @@ -51,8 +50,9 @@ module Pressa rows << "=> #{web_url_for("/posts/")} Read on the web" rows << "" - file_path = File.join(target_path, "posts", "index.gmi") - Utils::FileWriter.write(path: file_path, content: rows.join("\n")) + content = rows.join("\n") + Utils::FileWriter.write(path: File.join(target_path, "posts", "index.gmi"), content:) + Utils::FileWriter.write(path: File.join(target_path, "posts", "feed.gmi"), content:) end def write_year_indexes(target_path:) @@ -84,7 +84,7 @@ module Pressa rows << gemtext_body unless gemtext_body.empty? rows << "" unless rows.last.to_s.empty? - rows << "=> /posts/ Back to archive" + rows << "=> /posts Back to posts" rows << "=> #{web_url_for("#{post.path}/")} Read on the web" if include_web_link?(post) rows << "" @@ -99,12 +99,12 @@ module Pressa month = month_posts.month rows << "## #{month.name}" month_posts.sorted_posts.each do |post| - rows.concat(post_archive_lines(post)) + rows.concat(post_listing_lines(post)) end rows << "" end - rows << "=> /posts/ Back to archive" + rows << "=> /posts Back to posts" rows << "=> #{web_url_for("/posts/#{year}/")} Read on the web" rows << "" @@ -117,12 +117,12 @@ module Pressa rows = ["# #{month.name} #{year}", ""] month_posts.sorted_posts.each do |post| - rows.concat(post_archive_lines(post)) + rows.concat(post_listing_lines(post)) end rows << "" rows << "=> /posts/#{year}/ Back to year" - rows << "=> /posts/ Back to archive" + rows << "=> /posts Back to posts" rows << "=> #{web_url_for("/posts/#{year}/#{month.padded}/")} Read on the web" rows << "" @@ -134,7 +134,7 @@ module Pressa "=> #{post.path}/ #{post.date.strftime("%Y-%m-%d")} - #{post.title}" end - def post_archive_lines(post) + def post_listing_lines(post) rows = [post_link_line(post)] rows << "=> #{post.link}" if post.link_post? rows diff --git a/lib/pressa/posts/plugin.rb b/lib/pressa/posts/plugin.rb index cdf1335..c0c3f14 100644 --- a/lib/pressa/posts/plugin.rb +++ b/lib/pressa/posts/plugin.rb @@ -45,9 +45,7 @@ module Pressa writer = GeminiWriter.new(site:, posts_by_year: @posts_by_year) writer.write_posts(target_path:) writer.write_recent_posts(target_path:, limit: gemini_recent_posts_limit(site)) - writer.write_archive(target_path:) - writer.write_year_indexes(target_path:) - writer.write_month_rollups(target_path:) + writer.write_posts_index(target_path:) end private diff --git a/lib/pressa/utils/gemtext_renderer.rb b/lib/pressa/utils/gemtext_renderer.rb index 03f099c..518dd56 100644 --- a/lib/pressa/utils/gemtext_renderer.rb +++ b/lib/pressa/utils/gemtext_renderer.rb @@ -78,6 +78,10 @@ module Pressa def convert_text_line(line, link_reference_definitions) clean_text, links = extract_links(line, link_reference_definitions) + if !links.empty? && clean_inline_text(strip_links_from_text(line)).empty? + return render_link_rows(links) + end + rows = [] inline_text = clean_inline_text(clean_text) rows << inline_text unless inline_text.empty? @@ -130,7 +134,7 @@ module Pressa end def link_only_list_item?(text, link_reference_definitions) - clean_text, links = extract_links(text, link_reference_definitions) + _clean_text, links = extract_links(text, link_reference_definitions) return false if links.empty? remaining_text = strip_links_from_text(text) @@ -183,7 +187,26 @@ module Pressa cleaned.gsub!(/\*([^*]+)\*/, '\1') cleaned.gsub!(/_([^_]+)_/, '\1') cleaned.gsub!(/\s+/, " ") - CGI.unescapeHTML(cleaned).strip + cleaned = CGI.unescapeHTML(cleaned) + cleaned = decode_named_html_entities(cleaned) + cleaned.strip + end + + def decode_named_html_entities(text) + text.gsub(/&([A-Za-z]+);/) do + entity = Regexp.last_match(1).downcase + + case entity + when "darr" then "\u2193" + when "uarr" then "\u2191" + when "larr" then "\u2190" + when "rarr" then "\u2192" + when "hellip" then "..." + when "nbsp" then " " + else + "&#{Regexp.last_match(1)};" + end + end end def strip_html_tags(text) diff --git a/lib/pressa/views/icons.rb b/lib/pressa/views/icons.rb index 6a3c7dd..32396c3 100644 --- a/lib/pressa/views/icons.rb +++ b/lib/pressa/views/icons.rb @@ -19,6 +19,10 @@ module Pressa svg(class_name: "icon icon-code", view_box: "0 0 640 512", path: IconPath::CODE) end + def gemini + svg(class_name: "icon icon-gemini", view_box: "0 0 576 512", path: IconPath::GEMINI) + end + private_class_method def svg(class_name:, view_box:, path:) "" end @@ -28,6 +32,7 @@ module Pressa GITHUB = "M165.9 50.5996c0 -2 -2.30078 -3.59961 -5.2002 -3.59961c-3.2998 -0.299805 -5.60059 1.2998 -5.60059 3.59961c0 2 2.30078 3.60059 5.2002 3.60059c3 0.299805 5.60059 -1.2998 5.60059 -3.60059zM134.8 55.0996c0.700195 2 3.60059 3 6.2002 2.30078 c3 -0.900391 4.90039 -3.2002 4.2998 -5.2002c-0.599609 -2 -3.59961 -3 -6.2002 -2c-3 0.599609 -5 2.89941 -4.2998 4.89941zM179 56.7998c2.90039 0.299805 5.59961 -1 5.90039 -2.89941c0.299805 -2 -1.7002 -3.90039 -4.60059 -4.60059 c-3 -0.700195 -5.59961 0.600586 -5.89941 2.60059c-0.300781 2.2998 1.69922 4.19922 4.59961 4.89941zM244.8 440c138.7 0 251.2 -105.3 251.2 -244c0 -110.9 -67.7998 -205.8 -167.8 -239c-12.7002 -2.2998 -17.2998 5.59961 -17.2998 12.0996 c0 8.2002 0.299805 49.9004 0.299805 83.6006c0 23.5 -7.7998 38.5 -17 46.3994c55.8994 6.30078 114.8 14 114.8 110.5c0 27.4004 -9.7998 41.2002 -25.7998 58.9004c2.59961 6.5 11.0996 33.2002 -2.60059 67.9004c-20.8994 6.59961 -69 -27 -69 -27 c-20 5.59961 -41.5 8.5 -62.7998 8.5s-42.7998 -2.90039 -62.7998 -8.5c0 0 -48.0996 33.5 -69 27c-13.7002 -34.6006 -5.2002 -61.4004 -2.59961 -67.9004c-16 -17.5996 -23.6006 -31.4004 -23.6006 -58.9004c0 -96.1992 56.4004 -104.3 112.3 -110.5 c-7.19922 -6.59961 -13.6992 -17.6992 -16 -33.6992c-14.2998 -6.60059 -51 -17.7002 -72.8994 20.8994c-13.7002 23.7998 -38.6006 25.7998 -38.6006 25.7998c-24.5 0.300781 -1.59961 -15.3994 -1.59961 -15.3994c16.4004 -7.5 27.7998 -36.6006 27.7998 -36.6006 c14.7002 -44.7998 84.7002 -29.7998 84.7002 -29.7998c0 -21 0.299805 -55.2002 0.299805 -61.3994c0 -6.5 -4.5 -14.4004 -17.2998 -12.1006c-99.7002 33.4004 -169.5 128.3 -169.5 239.2c0 138.7 106.1 244 244.8 244zM97.2002 95.0996 c1.2998 1.30078 3.59961 0.600586 5.2002 -1c1.69922 -1.89941 2 -4.19922 0.699219 -5.19922c-1.2998 -1.30078 -3.59961 -0.600586 -5.19922 1c-1.7002 1.89941 -2 4.19922 -0.700195 5.19922zM86.4004 103.2c0.699219 1 2.2998 1.2998 4.2998 0.700195 c2 -1 3 -2.60059 2.2998 -3.90039c-0.700195 -1.40039 -2.7002 -1.7002 -4.2998 -0.700195c-2 1 -3 2.60059 -2.2998 3.90039zM118.8 67.5996c1.2998 1.60059 4.2998 1.30078 6.5 -1c2 -1.89941 2.60059 -4.89941 1.2998 -6.19922 c-1.2998 -1.60059 -4.19922 -1.30078 -6.5 1c-2.2998 1.89941 -2.89941 4.89941 -1.2998 6.19922zM107.4 82.2998c1.59961 1.2998 4.19922 0.299805 5.59961 -2c1.59961 -2.2998 1.59961 -4.89941 0 -6.2002c-1.2998 -1 -4 0 -5.59961 2.30078 c-1.60059 2.2998 -1.60059 4.89941 0 5.89941z" RSS = "M128.081 32.041c0 -35.3691 -28.6719 -64.041 -64.041 -64.041s-64.04 28.6719 -64.04 64.041s28.6719 64.041 64.041 64.041s64.04 -28.6729 64.04 -64.041zM303.741 -15.209c0.494141 -9.13477 -6.84668 -16.791 -15.9951 -16.79h-48.0693 c-8.41406 0 -15.4707 6.49023 -16.0176 14.8867c-7.29883 112.07 -96.9404 201.488 -208.772 208.772c-8.39648 0.545898 -14.8867 7.60254 -14.8867 16.0176v48.0693c0 9.14746 7.65625 16.4883 16.791 15.9941c154.765 -8.36328 278.596 -132.351 286.95 -286.95z M447.99 -15.4971c0.324219 -9.03027 -6.97168 -16.5029 -16.0049 -16.5039h-48.0684c-8.62598 0 -15.6455 6.83496 -15.999 15.4531c-7.83789 191.148 -161.286 344.626 -352.465 352.465c-8.61816 0.354492 -15.4531 7.37402 -15.4531 15.999v48.0684 c0 9.03418 7.47266 16.3301 16.5029 16.0059c234.962 -8.43555 423.093 -197.667 431.487 -431.487z" CODE = "M278.9 -63.5l-61 17.7002c-6.40039 1.7998 -10 8.5 -8.2002 14.8994l136.5 470.2c1.7998 6.40039 8.5 10 14.8994 8.2002l61 -17.7002c6.40039 -1.7998 10 -8.5 8.2002 -14.8994l-136.5 -470.2c-1.89941 -6.40039 -8.5 -10.1006 -14.8994 -8.2002zM164.9 48.7002 c-4.5 -4.90039 -12.1006 -5.10059 -17 -0.5l-144.101 135.1c-5.09961 4.7002 -5.09961 12.7998 0 17.5l144.101 135c4.89941 4.60059 12.5 4.2998 17 -0.5l43.5 -46.3994c4.69922 -4.90039 4.2998 -12.7002 -0.800781 -17.2002l-90.5996 -79.7002l90.5996 -79.7002 c5.10059 -4.5 5.40039 -12.2998 0.800781 -17.2002zM492.1 48.0996c-4.89941 -4.5 -12.5 -4.2998 -17 0.600586l-43.5 46.3994c-4.69922 4.90039 -4.2998 12.7002 0.800781 17.2002l90.5996 79.7002l-90.5996 79.7998c-5.10059 4.5 -5.40039 12.2998 -0.800781 17.2002 l43.5 46.4004c4.60059 4.7998 12.2002 5 17 0.5l144.101 -135.2c5.09961 -4.7002 5.09961 -12.7998 0 -17.5z" + GEMINI = "M543.79 125.6l-224 -99.5996c-19.2002 -8.5 -44.4004 -8.5 -63.6006 0l-224 99.5996c-32.2002 14.2998 -40.3008 52.2998 -15.8008 74.7002l224 204.5c9.7998 9 25.3008 14.1992 41.6006 14.1992s31.8008 -5.19922 41.6006 -14.1992l224 -204.5c24.499 -22.4004 16.3994 -60.4004 -15.8008 -74.7002z" end end end diff --git a/lib/pressa/views/layout.rb b/lib/pressa/views/layout.rb index 1e36ba9..99672b1 100644 --- a/lib/pressa/views/layout.rb +++ b/lib/pressa/views/layout.rb @@ -125,7 +125,7 @@ module Pressa h1 do a(href: site.url) { site.title } end - br + h4 do plain "By " a(href: site.url_for("/about")) { site.author } @@ -134,7 +134,7 @@ module Pressa nav(class: "remote") do ul do - html_remote_links.each do |link| + remote_nav_links.each do |link| li(class: remote_link_class(link)) do attrs = {"aria-label": link.label, href: remote_link_href(link.href)} attrs[:rel] = "me" if mastodon_link?(link) @@ -177,6 +177,92 @@ module Pressa attrs[:defer] = true if scr.defer script(**attrs) end + + render_gemini_fallback_script + end + + def render_gemini_fallback_script + # Inline so the behavior ships with the base HTML layout without needing + # separate asset management for one small handler. + script do + raw(safe(<<~JS)) + (function () { + function isPlainLeftClick(e) { + return ( + e.button === 0 && + !e.defaultPrevented && + !e.metaKey && + !e.ctrlKey && + !e.shiftKey && + !e.altKey + ); + } + + function setupGeminiFallback() { + var link = document.querySelector( + 'header.primary nav.remote li.gemini a[href^="gemini://"]' + ); + if (!link) return; + + link.addEventListener("click", function (e) { + if (!isPlainLeftClick(e)) return; + + e.preventDefault(); + + var geminiHref = link.getAttribute("href"); + var fallbackHref = "https://geminiprotocol.net"; + + var done = false; + var fallbackTimer = null; + + function cleanup() { + if (fallbackTimer) window.clearTimeout(fallbackTimer); + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("pagehide", onPageHide); + window.removeEventListener("blur", onBlur); + } + + function markDone() { + done = true; + cleanup(); + } + + function onVisibilityChange() { + // If a handler opens and the browser backgrounded, consider it "successful". + if (document.visibilityState === "hidden") markDone(); + } + + function onPageHide() { + markDone(); + } + + function onBlur() { + // Some browsers blur the page when a protocol handler is invoked. + markDone(); + } + + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("pagehide", onPageHide, { once: true }); + window.addEventListener("blur", onBlur, { once: true }); + + // If we're still here shortly after attempting navigation, assume it failed. + fallbackTimer = window.setTimeout(function () { + if (done) return; + window.location.href = fallbackHref; + }, 900); + + window.location.href = geminiHref; + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", setupGeminiFallback); + } else { + setupGeminiFallback(); + } + })(); + JS + end end def script_src(src) @@ -208,8 +294,12 @@ module Pressa site.html_output_options&.remote_links || [] end + def remote_nav_links + html_remote_links + end + def remote_link_href(href) - return href if href.start_with?("http://", "https://") + return href if href.match?(/\A[a-z][a-z0-9+\-.]*:/i) absolute_asset(href) end @@ -220,6 +310,16 @@ module Pressa end def remote_link_icon_markup(link) + # Gemini doesn't have an obvious, widely-recognized protocol icon. + # Use a simple custom SVG mark so it aligns like the other SVG icons. + if link.icon == "gemini" + return <<~SVG.strip + + SVG + end + icon_renderer = remote_link_icon_renderer(link.icon) return nil unless icon_renderer @@ -232,6 +332,8 @@ module Pressa when "github" then :github when "rss" then :rss when "code" then :code + # The Gemini SVG icon was too ambiguous in practice; render this as text instead. + when "gemini" then nil end end diff --git a/public/css/style.css b/public/css/style.css index 47ec4a9..00a40e2 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -160,7 +160,7 @@ header.primary .title { header.primary h1, header.primary h4 { - display: inline-block; + display: block; margin: 0; padding: 0; word-wrap: break-word; @@ -180,6 +180,8 @@ header.primary h1 a:visited { color: #f7f7f7; } +/* (protocol-link styles removed; Gemini now renders in the remote icon row) */ + header.primary h4 { font-size: 0.8rem; line-height: 1.2; @@ -264,6 +266,15 @@ header.primary nav ul li.github .icon { color: #4183c4; } +header.primary nav ul li.gemini a, +header.primary nav ul li.gemini a:visited { + color: #bdbdbd; +} + +header.primary nav ul li.gemini a:hover { + color: #f7f7f7; +} + footer { padding: 0 env(safe-area-inset-right) 2rem env(safe-area-inset-left); text-align: center; diff --git a/site.toml b/site.toml index ce3b44e..8d34338 100644 --- a/site.toml +++ b/site.toml @@ -22,17 +22,20 @@ styles = [] remote_links = [ {"label": "Mastodon", "href": "https://techhub.social/@sjs", "icon": "mastodon"}, {"label": "GitHub", "href": "https://github.com/samsonjs", "icon": "github"}, - {"label": "RSS", "href": "/feed.xml", "icon": "rss"} + {"label": "RSS", "href": "/feed.xml", "icon": "rss"}, + {"label": "Gemini", "href": "gemini://samhuri.net", "icon": "gemini"} ] [outputs.gemini] recent_posts_limit = 20 home_links = [ - {"label": "About", "href": "/about/"}, - {"label": "Projects", "href": "/projects/"}, + {"label": "About", "href": "/about"}, + {"label": "Posts", "href": "/posts"}, + {"label": "Projects", "href": "/projects"}, {"label": "Mastodon", "href": "https://techhub.social/@sjs"}, {"label": "GitHub", "href": "https://github.com/samsonjs"}, - {"label": "RSS", "href": "/feed.xml"}, + {"label": "RSS (Gemini)", "href": "/posts/feed.gmi"}, + {"label": "RSS (Web)", "href": "https://samhuri.net/feed.xml"}, {"label": "Email", "href": "mailto:sami@samhuri.net"} ] exclude_public = [ diff --git a/test/posts/gemini_plugin_test.rb b/test/posts/gemini_plugin_test.rb index 0720c0f..5bc9fab 100644 --- a/test/posts/gemini_plugin_test.rb +++ b/test/posts/gemini_plugin_test.rb @@ -69,8 +69,9 @@ class Pressa::Posts::GeminiPluginTest < Minitest::Test assert(File.exist?(File.join(target_path, "index.gmi"))) assert(File.exist?(File.join(target_path, "posts/index.gmi"))) - assert(File.exist?(File.join(target_path, "posts/2025/index.gmi"))) - assert(File.exist?(File.join(target_path, "posts/2025/11/index.gmi"))) + assert(File.exist?(File.join(target_path, "posts/feed.gmi"))) + refute(File.exist?(File.join(target_path, "posts/2025/index.gmi"))) + refute(File.exist?(File.join(target_path, "posts/2025/11/index.gmi"))) markdown_post = File.join(target_path, "posts/2025/11/markdown-only/index.gmi") html_post = File.join(target_path, "posts/2025/11/html-heavy/index.gmi") @@ -81,8 +82,8 @@ class Pressa::Posts::GeminiPluginTest < Minitest::Test index_text = File.read(File.join(target_path, "index.gmi")) markdown_text = File.read(markdown_post) html_text = File.read(html_post) - year_index = File.read(File.join(target_path, "posts/2025/index.gmi")) - month_index = File.read(File.join(target_path, "posts/2025/11/index.gmi")) + archive_text = File.read(File.join(target_path, "posts/index.gmi")) + feed_text = File.read(File.join(target_path, "posts/feed.gmi")) assert_includes(index_text, "=> /about/") assert_includes(index_text, "=> https://techhub.social/@sjs") @@ -92,12 +93,13 @@ class Pressa::Posts::GeminiPluginTest < Minitest::Test assert_includes(html_text, "Read on the web") assert_includes(markdown_text, "=> https://example.com") assert_includes(html_text, "=> https://example.org") - assert_includes(year_index, "## November") - refute_includes(year_index, "=> /posts/2025/11/ November 2025") - assert_match(%r{=> /posts/2025/11/link-post/ 2025-11-07 - Link Post\n=> https://example.net/story}, year_index) - assert_includes(year_index, "=> /posts/2025/11/html-heavy/ 2025-11-06 - HTML Heavy") - assert_includes(year_index, "=> /posts/2025/11/markdown-only/ 2025-11-05 - Markdown Only") - assert_match(%r{=> /posts/2025/11/link-post/ 2025-11-07 - Link Post\n=> https://example.net/story}, month_index) + assert_includes(markdown_text, "=> /posts Back to posts") + assert_includes(archive_text, "# samhuri.net posts") + assert_includes(archive_text, "## Feed") + assert_match(%r{=> /posts/2025/11/link-post/ 2025-11-07 - Link Post\n=> https://example.net/story}, archive_text) + assert_includes(archive_text, "=> /posts/2025/11/html-heavy/ 2025-11-06 - HTML Heavy") + assert_includes(archive_text, "=> /posts/2025/11/markdown-only/ 2025-11-05 - Markdown Only") + assert_equal(archive_text, feed_text) end end end diff --git a/test/utils/gemtext_renderer_test.rb b/test/utils/gemtext_renderer_test.rb index 86fd354..0831df2 100644 --- a/test/utils/gemtext_renderer_test.rb +++ b/test/utils/gemtext_renderer_test.rb @@ -50,4 +50,23 @@ class Pressa::Utils::GemtextRendererTest < Minitest::Test refute_includes(rendered, "* GitHub: samsonjs") refute_includes(rendered, "* Stack Overflow") end + + def test_render_decodes_common_named_html_entities + markdown = "a → b … and down ↓" + rendered = Pressa::Utils::GemtextRenderer.render(markdown) + + assert_includes(rendered, "a \u2192 b ... and down \u2193") + refute_includes(rendered, "→") + refute_includes(rendered, "↓") + end + + def test_render_collapses_link_only_text_lines_to_links + markdown = <<~MARKDOWN + ↓ Download volume.rb + MARKDOWN + + rendered = Pressa::Utils::GemtextRenderer.render(markdown) + + assert_equal("=> /f/volume.rb", rendered) + end end diff --git a/test/views/layout_test.rb b/test/views/layout_test.rb index 6f41547..c186f44 100644 --- a/test/views/layout_test.rb +++ b/test/views/layout_test.rb @@ -112,6 +112,7 @@ class Pressa::Views::LayoutTest < Minitest::Test html = Pressa::Views::Layout.new( site: site_with_remote_links([ Pressa::OutputLink.new(label: "Mastodon", href: "https://techhub.social/@sjs", icon: "mastodon"), + Pressa::OutputLink.new(label: "Gemini", href: "gemini://samhuri.net", icon: "gemini"), Pressa::OutputLink.new(label: "GitHub", href: "https://github.com/samsonjs", icon: "github"), Pressa::OutputLink.new(label: "RSS", href: "/feed.xml", icon: "rss") ]), @@ -120,9 +121,11 @@ class Pressa::Views::LayoutTest < Minitest::Test ).call assert_includes(html, "href=\"https://techhub.social/@sjs\"") + assert_includes(html, "href=\"gemini://samhuri.net\"") assert_includes(html, "href=\"https://github.com/samsonjs\"") assert_includes(html, "href=\"https://samhuri.net/feed.xml\"") assert_includes(html, "aria-label=\"Mastodon\"") + assert_includes(html, "aria-label=\"Gemini\"") assert_includes(html, "aria-label=\"GitHub\"") assert_includes(html, "aria-label=\"RSS\"") end