Publish gemini feeds, add link from web, tweak things

This commit is contained in:
Sami Samhuri 2026-02-14 16:10:48 -08:00
parent e466475d3b
commit 5255247823
No known key found for this signature in database
10 changed files with 203 additions and 37 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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:)
"<svg class=\"#{class_name}\" viewBox=\"#{view_box}\" aria-hidden=\"true\" focusable=\"false\"><path transform=\"translate(0,448) scale(1,-1)\" d=\"#{path}\"/></svg>"
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

View file

@ -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 class="icon icon-gemini-protocol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path transform="translate(12 12) scale(0.84 1.04) translate(-12 -12)" d="M18,5.3C19.35,4.97 20.66,4.54 21.94,4L21.18,2.14C18.27,3.36 15.15,4 12,4C8.85,4 5.73,3.38 2.82,2.17L2.06,4C3.34,4.54 4.65,4.97 6,5.3V18.7C4.65,19.03 3.34,19.46 2.06,20L2.82,21.86C8.7,19.42 15.3,19.42 21.18,21.86L21.94,20C20.66,19.46 19.35,19.03 18,18.7V5.3M8,18.3V5.69C9.32,5.89 10.66,6 12,6C13.34,6 14.68,5.89 16,5.69V18.31C13.35,17.9 10.65,17.9 8,18.31V18.3Z"/>
</svg>
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

View file

@ -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;

View file

@ -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 = [

View file

@ -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

View file

@ -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 &rarr; b &hellip; and down &darr;"
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
assert_includes(rendered, "a \u2192 b ... and down \u2193")
refute_includes(rendered, "&rarr;")
refute_includes(rendered, "&darr;")
end
def test_render_collapses_link_only_text_lines_to_links
markdown = <<~MARKDOWN
<a href="/f/volume.rb">&darr; Download volume.rb</a>
MARKDOWN
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
assert_equal("=> /f/volume.rb", rendered)
end
end

View file

@ -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