Publish a Gemini site and link to it from the website (#36)

* Publish on gemini in addition to the web

* Publish gemini feeds, add link from web, tweak things
This commit is contained in:
Sami Samhuri 2026-02-14 17:18:09 -08:00 committed by GitHub
parent 48ca00ed21
commit 9a0b182879
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1663 additions and 62 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
www www
gemini
Tests/*/actual Tests/*/actual

View file

@ -1,7 +1,7 @@
# Repository Guidelines # Repository Guidelines
## Project Structure & Module Organization ## Project Structure & Module Organization
This repository is a single Ruby static-site generator project (the legacy Swift generators were removed). This repository is a Ruby static-site generator (Pressa) that outputs both HTML and Gemini formats.
- Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`) - Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
- Build/deploy/draft tasks: `bake.rb` - Build/deploy/draft tasks: `bake.rb`
@ -10,21 +10,28 @@ This repository is a single Ruby static-site generator project (the legacy Swift
- Published posts: `posts/YYYY/MM/*.md` - Published posts: `posts/YYYY/MM/*.md`
- Static and renderable public content: `public/` - Static and renderable public content: `public/`
- Draft posts: `public/drafts/` - Draft posts: `public/drafts/`
- Generated output: `www/` (safe to delete/regenerate) - Generated HTML output: `www/` (safe to delete/regenerate)
- Generated Gemini output: `gemini/` (safe to delete/regenerate)
- Gemini protocol reference docs: `gemini-docs/`
- CI: `.github/workflows/ci.yml` (runs coverage, lint, and debug build)
Keep new code under the existing `Pressa` module structure (for example `lib/pressa/posts`, `lib/pressa/projects`, `lib/pressa/views`, `lib/pressa/config`, `lib/pressa/utils`) and add matching tests under `test/`. Keep new code under the existing `Pressa` module structure (for example `lib/pressa/posts`, `lib/pressa/projects`, `lib/pressa/views`, `lib/pressa/config`, `lib/pressa/utils`) and add matching tests under `test/`.
## Setup, Build, Test, and Development Commands ## Setup, Build, Test, and Development Commands
- Use `rbenv exec` for Ruby commands in this repository (for example `rbenv exec bundle exec ...`) to ensure the project Ruby version is used. - Use `rbenv exec` for Ruby commands in this repository (for example `rbenv exec bundle exec ...`) to ensure the project Ruby version is used.
- `bin/bootstrap`: install prerequisites and gems (uses `rbenv` when available). - `bin/bootstrap`: install prerequisites and gems (uses `rbenv` when available).
- `rbenv exec bundle exec bake debug`: build for `http://localhost:8000` into `www/`. - `rbenv exec bundle exec bake debug`: build HTML for `http://localhost:8000` into `www/`.
- `rbenv exec bundle exec bake serve`: serve `www/` via WEBrick on port 8000. - `rbenv exec bundle exec bake serve`: serve `www/` via WEBrick on port 8000.
- `rbenv exec bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required). - `rbenv exec bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
- `rbenv exec bundle exec bake mudge|beta|release`: build with environment-specific base URLs. - `rbenv exec bundle exec bake mudge|beta|release`: build HTML with environment-specific base URLs.
- `rbenv exec bundle exec bake publish_beta|publish`: build and rsync `www/` to remote host. - `rbenv exec bundle exec bake gemini`: build Gemini capsule into `gemini/`.
- `rbenv exec bundle exec bake clean`: remove `www/`. - `rbenv exec bundle exec bake publish_beta`: build and rsync `www/` to beta host.
- `rbenv exec bundle exec bake publish_gemini`: build and rsync `gemini/` to production host.
- `rbenv exec bundle exec bake publish`: build and rsync both HTML and Gemini to production.
- `rbenv exec bundle exec bake clean`: remove `www/` and `gemini/`.
- `rbenv exec bundle exec bake test`: run test suite. - `rbenv exec bundle exec bake test`: run test suite.
- `rbenv exec bundle exec bake lint`: lint code. - `rbenv exec bundle exec bake guard`: run Guard for continuous testing.
- `rbenv exec bundle exec bake lint`: lint code with StandardRB.
- `rbenv exec bundle exec bake lint_fix`: auto-fix lint issues. - `rbenv exec bundle exec bake lint_fix`: auto-fix lint issues.
- `rbenv exec bundle exec bake coverage`: run tests and report `lib/` line coverage. - `rbenv exec bundle exec bake coverage`: run tests and report `lib/` line coverage.
- `rbenv exec bundle exec bake coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed). - `rbenv exec bundle exec bake coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed).
@ -49,7 +56,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
- Follow idiomatic Ruby style and keep code `bake lint`-clean. - Follow idiomatic Ruby style and keep code `bake lint`-clean.
- Use 2-space indentation and descriptive `snake_case` names for methods/variables, `UpperCamelCase` for classes/modules. - Use 2-space indentation and descriptive `snake_case` names for methods/variables, `UpperCamelCase` for classes/modules.
- Prefer small, focused classes for plugins, views, renderers, and config loaders. - Prefer small, focused classes for plugins, views, renderers, and config loaders.
- Do not hand-edit generated files in `www/`. - Do not hand-edit generated files in `www/` or `gemini/`.
## Testing Guidelines ## Testing Guidelines
- Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`). - Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`).
@ -69,7 +76,9 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
## Deployment & Security Notes ## Deployment & Security Notes
- Deployment is defined in `bake.rb` via rsync over SSH. - Deployment is defined in `bake.rb` via rsync over SSH.
- Current publish host is `mudge` with: - Current publish host is `mudge` with:
- production: `/var/www/samhuri.net/public` - production HTML: `/var/www/samhuri.net/public`
- beta: `/var/www/beta.samhuri.net/public` - beta HTML: `/var/www/beta.samhuri.net/public`
- Validate `www/` before publishing to avoid shipping stale assets. - production Gemini: `/var/gemini/samhuri.net`
- `bake publish` deploys both HTML and Gemini to production.
- Validate `www/` and `gemini/` before publishing to avoid shipping stale assets.
- Never commit credentials, SSH keys, or other secrets. - Never commit credentials, SSH keys, or other secrets.

View file

@ -13,7 +13,7 @@ If what you want is an artisanal, hand-crafted, static site generator for your p
- Tests: `test/` - Tests: `test/`
- Config: `site.toml` and `projects.toml` - Config: `site.toml` and `projects.toml`
- Content: `posts/` and `public/` - Content: `posts/` and `public/`
- Output: `www/` - Output: `www/` (HTML), `gemini/` (Gemini capsule)
## Requirements ## Requirements
@ -45,7 +45,7 @@ bake serve # serve www/ locally
Site metadata and project data are configured with TOML files at the repository root: Site metadata and project data are configured with TOML files at the repository root:
- `site.toml`: site identity, default scripts/styles, and a `plugins` list (for example `["posts", "projects"]`), plus `projects_plugin` assets when that plugin is enabled. - `site.toml`: site identity, default scripts/styles, a `plugins` list (for example `["posts", "projects"]`), and output-specific settings under `outputs.*` (for example `outputs.html.remote_links` and `outputs.gemini.{exclude_public,recent_posts_limit,home_links}`), plus `projects_plugin` assets when that plugin is enabled.
- `projects.toml`: project listing entries using `[[projects]]`. - `projects.toml`: project listing entries using `[[projects]]`.
`Pressa.create_site` loads both files from the provided `source_path` and supports URL overrides for `debug`, `beta`, and `release` builds. `Pressa.create_site` loads both files from the provided `source_path` and supports URL overrides for `debug`, `beta`, and `release` builds.
@ -58,6 +58,9 @@ If this workflow seems like a good fit, here is the minimum to make it your own:
- Set `plugins` in `site.toml` to explicitly enable features (`"posts"`, `"projects"`). Safe default if omitted is no plugins. - Set `plugins` in `site.toml` to explicitly enable features (`"posts"`, `"projects"`). Safe default if omitted is no plugins.
- Define your projects in `projects.toml` using `[[projects]]` entries with `name`, `title`, `description`, and `url`. - Define your projects in `projects.toml` using `[[projects]]` entries with `name`, `title`, `description`, and `url`.
- Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`) when using the `"projects"` plugin. - Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`) when using the `"projects"` plugin.
- Configure output pipelines with `site.toml` `outputs.*` tables:
- `[outputs.html]` supports `remote_links` (array of `{label, href, icon}`).
- `[outputs.gemini]` supports `exclude_public`, `recent_posts_limit`, and `home_links` (array of `{label, href}`).
- Add custom plugins by implementing `Pressa::Plugin` in `lib/pressa/` and registering them in `lib/pressa/config/loader.rb`. - Add custom plugins by implementing `Pressa::Plugin` in `lib/pressa/` and registering them in `lib/pressa/config/loader.rb`.
- Adjust rendering and layout in `lib/pressa/views/` and the static content in `public/` as needed. - Adjust rendering and layout in `lib/pressa/views/` and the static content in `public/` as needed.
@ -67,9 +70,11 @@ Other targets:
bake mudge bake mudge
bake beta bake beta
bake release bake release
bake gemini
bake watch target=debug bake watch target=debug
bake clean bake clean
bake publish_beta bake publish_beta
bake publish_gemini
bake publish bake publish
``` ```
@ -109,3 +114,4 @@ bake lint_fix
- Deployment uses `rsync` to host `mudge` (configured in `bake.rb`): - Deployment uses `rsync` to host `mudge` (configured in `bake.rb`):
- production: `/var/www/samhuri.net/public` - production: `/var/www/samhuri.net/public`
- beta: `/var/www/beta.samhuri.net/public` - beta: `/var/www/beta.samhuri.net/public`
- gemini: `/var/gemini/samhuri.net`

44
bake.rb
View file

@ -12,28 +12,34 @@ DRAFTS_DIR = "public/drafts".freeze
PUBLISH_HOST = "mudge".freeze PUBLISH_HOST = "mudge".freeze
PRODUCTION_PUBLISH_DIR = "/var/www/samhuri.net/public".freeze PRODUCTION_PUBLISH_DIR = "/var/www/samhuri.net/public".freeze
BETA_PUBLISH_DIR = "/var/www/beta.samhuri.net/public".freeze BETA_PUBLISH_DIR = "/var/www/beta.samhuri.net/public".freeze
GEMINI_PUBLISH_DIR = "/var/gemini/samhuri.net".freeze
WATCHABLE_DIRECTORIES = %w[public posts lib].freeze WATCHABLE_DIRECTORIES = %w[public posts lib].freeze
LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze
BUILD_TARGETS = %w[debug mudge beta release].freeze BUILD_TARGETS = %w[debug mudge beta release gemini].freeze
# Generate the site in debug mode (localhost:8000) # Generate the site in debug mode (localhost:8000)
def debug def debug
build("http://localhost:8000") build("http://localhost:8000", output_format: "html", target_path: "www")
end end
# Generate the site for the mudge development server # Generate the site for the mudge development server
def mudge def mudge
build("http://mudge:8000") build("http://mudge:8000", output_format: "html", target_path: "www")
end end
# Generate the site for beta/staging # Generate the site for beta/staging
def beta def beta
build("https://beta.samhuri.net") build("https://beta.samhuri.net", output_format: "html", target_path: "www")
end end
# Generate the site for production # Generate the site for production
def release def release
build("https://samhuri.net") build("https://samhuri.net", output_format: "html", target_path: "www")
end
# Generate the Gemini capsule for production
def gemini
build("https://samhuri.net", output_format: "gemini", target_path: "gemini")
end end
# Start local development server # Start local development server
@ -109,7 +115,7 @@ def publish_draft(input_path = nil)
end end
# Watch content directories and rebuild on every change. # Watch content directories and rebuild on every change.
# @parameter target [String] One of debug, mudge, beta, or release. # @parameter target [String] One of debug, mudge, beta, release, or gemini.
def watch(target: "debug") def watch(target: "debug")
unless command_available?("inotifywait") unless command_available?("inotifywait")
abort "inotifywait is required (install inotify-tools)." abort "inotifywait is required (install inotify-tools)."
@ -129,16 +135,24 @@ def publish_beta
run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true) run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true)
end end
# Publish Gemini capsule to production
def publish_gemini
gemini
run_rsync(local_paths: ["gemini/"], publish_dir: GEMINI_PUBLISH_DIR, dry_run: false, delete: true)
end
# Publish to production server # Publish to production server
def publish def publish
release release
run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true) run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true)
publish_gemini
end end
# Clean generated files # Clean generated files
def clean def clean
FileUtils.rm_rf("www") FileUtils.rm_rf("www")
puts "Cleaned www/ directory" FileUtils.rm_rf("gemini")
puts "Cleaned www/ and gemini/ directories"
end end
# Default task: run coverage and lint. # Default task: run coverage and lint.
@ -358,16 +372,18 @@ def capture_command_optional(*command, chdir: Dir.pwd)
"" ""
end end
# Build the site with specified URL # Build the site with specified URL and output format.
# @parameter url [String] The site URL to use # @parameter url [String] The site URL to use.
def build(url) # @parameter output_format [String] One of html or gemini.
# @parameter target_path [String] Target directory for generated output.
def build(url, output_format:, target_path:)
require "pressa" require "pressa"
puts "Building site for #{url}..." puts "Building #{output_format} site for #{url}..."
site = Pressa.create_site(source_path: ".", url_override: url) site = Pressa.create_site(source_path: ".", url_override: url, output_format:)
generator = Pressa::SiteGenerator.new(site:) generator = Pressa::SiteGenerator.new(site:)
generator.generate(source_path: ".", target_path: "www") generator.generate(source_path: ".", target_path:)
puts "Site built successfully in www/" puts "Site built successfully in #{target_path}/"
end end
def run_build_target(target) def run_build_target(target)

View file

@ -4,11 +4,12 @@ require "pressa/plugin"
require "pressa/posts/plugin" require "pressa/posts/plugin"
require "pressa/projects/plugin" require "pressa/projects/plugin"
require "pressa/utils/markdown_renderer" require "pressa/utils/markdown_renderer"
require "pressa/utils/gemini_markdown_renderer"
require "pressa/config/loader" require "pressa/config/loader"
module Pressa module Pressa
def self.create_site(source_path: ".", url_override: nil) def self.create_site(source_path: ".", url_override: nil, output_format: "html")
loader = Config::Loader.new(source_path:) loader = Config::Loader.new(source_path:)
loader.build_site(url_override:) loader.build_site(url_override:, output_format:)
end end
end end

View file

@ -2,6 +2,7 @@ require "pressa/site"
require "pressa/posts/plugin" require "pressa/posts/plugin"
require "pressa/projects/plugin" require "pressa/projects/plugin"
require "pressa/utils/markdown_renderer" require "pressa/utils/markdown_renderer"
require "pressa/utils/gemini_markdown_renderer"
require "pressa/config/simple_toml" require "pressa/config/simple_toml"
module Pressa module Pressa
@ -16,13 +17,16 @@ module Pressa
@source_path = source_path @source_path = source_path
end end
def build_site(url_override: nil) def build_site(url_override: nil, output_format: "html")
site_config = load_toml("site.toml") site_config = load_toml("site.toml")
validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml") validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml")
validate_no_legacy_output_keys!(site_config)
normalized_output_format = normalize_output_format(output_format)
site_url = url_override || site_config["url"] site_url = url_override || site_config["url"]
plugins = build_plugins(site_config) output_options = build_output_options(site_config:, output_format: normalized_output_format)
plugins = build_plugins(site_config, output_format: normalized_output_format)
Site.new( Site.new(
author: site_config["author"], author: site_config["author"],
@ -30,13 +34,17 @@ module Pressa
title: site_config["title"], title: site_config["title"],
description: site_config["description"], description: site_config["description"],
url: site_url, url: site_url,
fediverse_creator: build_optional_string(
site_config["fediverse_creator"],
context: "site.toml fediverse_creator"
),
image_url: normalize_image_url(site_config["image_url"], site_url), image_url: normalize_image_url(site_config["image_url"], site_url),
scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"), scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"),
styles: build_styles(site_config["styles"], context: "site.toml styles"), styles: build_styles(site_config["styles"], context: "site.toml styles"),
plugins:, plugins:,
renderers: [ renderers: build_renderers(output_format: normalized_output_format),
Utils::MarkdownRenderer.new output_format: normalized_output_format,
] output_options:
) )
end end
@ -80,21 +88,53 @@ module Pressa
raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}" raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}"
end end
def build_plugins(site_config) def validate_no_legacy_output_keys!(site_config)
if site_config.key?("output")
raise ValidationError, "Legacy key 'output' is no longer supported; use 'outputs'"
end
if site_config.key?("mastodon_url") || site_config.key?("github_url")
raise ValidationError, "Legacy keys 'mastodon_url'/'github_url' are no longer supported; use outputs.html.remote_links or outputs.gemini.home_links"
end
end
def build_plugins(site_config, output_format:)
plugin_names = parse_plugin_names(site_config["plugins"]) plugin_names = parse_plugin_names(site_config["plugins"])
plugin_names.map.with_index do |plugin_name, index| plugin_names.map.with_index do |plugin_name, index|
case plugin_name case plugin_name
when "posts" when "posts"
Posts::Plugin.new posts_plugin_for(output_format)
when "projects" when "projects"
build_projects_plugin(site_config) build_projects_plugin(site_config, output_format:)
else else
raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]" raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]"
end end
end end
end end
def build_renderers(output_format:)
case output_format
when "html"
[Utils::MarkdownRenderer.new]
when "gemini"
[Utils::GeminiMarkdownRenderer.new]
else
raise ValidationError, "Unsupported output format '#{output_format}'"
end
end
def posts_plugin_for(output_format)
case output_format
when "html"
Posts::HTMLPlugin.new
when "gemini"
Posts::GeminiPlugin.new
else
raise ValidationError, "Unsupported output format '#{output_format}'"
end
end
def parse_plugin_names(value) def parse_plugin_names(value)
return [] if value.nil? return [] if value.nil?
raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array) raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array)
@ -116,16 +156,23 @@ module Pressa
end end
end end
def build_projects_plugin(site_config) def build_projects_plugin(site_config, output_format:)
projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin") projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin")
projects_config = load_toml("projects.toml") projects_config = load_toml("projects.toml")
projects = build_projects(projects_config) projects = build_projects(projects_config)
Projects::Plugin.new( case output_format
projects:, when "html"
scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"), Projects::HTMLPlugin.new(
styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles") projects:,
) scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"),
styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles")
)
when "gemini"
Projects::GeminiPlugin.new(projects:)
else
raise ValidationError, "Unsupported output format '#{output_format}'"
end
end end
def hash_or_empty(value, context) def hash_or_empty(value, context)
@ -135,6 +182,78 @@ module Pressa
raise ValidationError, "Expected #{context} to be a table" raise ValidationError, "Expected #{context} to be a table"
end end
def build_output_options(site_config:, output_format:)
outputs_config = hash_or_empty(site_config["outputs"], "site.toml outputs")
validate_allowed_keys!(
outputs_config,
allowed_keys: %w[html gemini],
context: "site.toml outputs"
)
format_config = hash_or_empty(outputs_config[output_format], "site.toml outputs.#{output_format}")
case output_format
when "html"
build_html_output_options(format_config:)
when "gemini"
build_gemini_output_options(format_config:)
else
raise ValidationError, "Unsupported output format '#{output_format}'"
end
end
def build_html_output_options(format_config:)
validate_allowed_keys!(
format_config,
allowed_keys: %w[exclude_public remote_links],
context: "site.toml outputs.html"
)
public_excludes = build_public_excludes(
format_config["exclude_public"],
context: "site.toml outputs.html.exclude_public"
)
remote_links = build_output_links(
format_config["remote_links"],
context: "site.toml outputs.html.remote_links",
allow_icon: true,
allow_label_optional: false,
allow_string_entries: false
)
HTMLOutputOptions.new(
public_excludes:,
remote_links:
)
end
def build_gemini_output_options(format_config:)
validate_allowed_keys!(
format_config,
allowed_keys: %w[exclude_public recent_posts_limit home_links],
context: "site.toml outputs.gemini"
)
public_excludes = build_public_excludes(
format_config["exclude_public"],
context: "site.toml outputs.gemini.exclude_public"
)
home_links = build_output_links(
format_config["home_links"],
context: "site.toml outputs.gemini.home_links",
allow_icon: false,
allow_label_optional: true,
allow_string_entries: true
)
recent_posts_limit = build_recent_posts_limit(
format_config["recent_posts_limit"],
context: "site.toml outputs.gemini.recent_posts_limit"
)
GeminiOutputOptions.new(
public_excludes:,
recent_posts_limit:,
home_links:
)
end
def build_scripts(value, context:) def build_scripts(value, context:)
entries = array_or_empty(value, context) entries = array_or_empty(value, context)
@ -212,6 +331,110 @@ module Pressa
raise ValidationError, "Expected #{context} to start with / or use http(s) scheme" raise ValidationError, "Expected #{context} to start with / or use http(s) scheme"
end end
def build_public_excludes(value, context:)
entries = array_or_empty(value, context)
entries.map.with_index do |entry, index|
unless entry.is_a?(String) && !entry.strip.empty?
raise ValidationError, "Expected #{context}[#{index}] to be a non-empty String"
end
entry.strip
end
end
def build_output_links(value, context:, allow_icon:, allow_label_optional:, allow_string_entries:)
entries = array_or_empty(value, context)
entries.map.with_index do |entry, index|
if allow_string_entries && entry.is_a?(String)
href = entry
unless !href.strip.empty?
raise ValidationError, "Expected #{context}[#{index}] to be a non-empty String"
end
validate_link_href!(href.strip, context: "#{context}[#{index}]")
next OutputLink.new(label: nil, href: href.strip, icon: nil)
end
unless entry.is_a?(Hash)
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
end
allowed_keys = allow_icon ? %w[label href icon] : %w[label href]
validate_allowed_keys!(
entry,
allowed_keys:,
context: "#{context}[#{index}]"
)
href = entry["href"]
unless href.is_a?(String) && !href.strip.empty?
raise ValidationError, "Expected #{context}[#{index}].href to be a non-empty String"
end
validate_link_href!(href.strip, context: "#{context}[#{index}].href")
label = entry["label"]
if label.nil?
unless allow_label_optional
raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String"
end
else
unless label.is_a?(String) && !label.strip.empty?
raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String"
end
end
icon = entry["icon"]
unless allow_icon
if entry.key?("icon")
raise ValidationError, "Unexpected #{context}[#{index}].icon; icons are only supported for outputs.html.remote_links"
end
icon = nil
end
if allow_icon && !icon.nil? && (!icon.is_a?(String) || icon.strip.empty?)
raise ValidationError, "Expected #{context}[#{index}].icon to be a non-empty String"
end
OutputLink.new(label: label&.strip, href: href.strip, icon: icon&.strip)
end
end
def validate_link_href!(value, context:)
return if value.start_with?("/")
return if value.match?(/\A[a-z][a-z0-9+\-.]*:/i)
raise ValidationError, "Expected #{context} to start with / or include a URI scheme"
end
def build_recent_posts_limit(value, context:)
return 20 if value.nil?
return value if value.is_a?(Integer) && value.positive?
raise ValidationError, "Expected #{context} to be a positive Integer"
end
def normalize_output_format(output_format)
value = output_format.to_s.strip.downcase
return value if %w[html gemini].include?(value)
raise ValidationError, "Unsupported output format '#{output_format}'"
end
def build_optional_string(value, context:)
return nil if value.nil?
return value if value.is_a?(String) && !value.strip.empty?
raise ValidationError, "Expected #{context} to be a non-empty String"
end
def validate_allowed_keys!(hash, allowed_keys:, context:)
unknown = hash.keys - allowed_keys
return if unknown.empty?
raise ValidationError, "Unknown key(s) in #{context}: #{unknown.join(", ")}"
end
end end
end end
end end

View file

@ -0,0 +1,111 @@
require "pressa/utils/file_writer"
require "pressa/utils/gemtext_renderer"
module Pressa
module Posts
class GeminiWriter
RECENT_POSTS_LIMIT = 20
def initialize(site:, posts_by_year:)
@site = site
@posts_by_year = posts_by_year
end
def write_posts(target_path:)
@posts_by_year.all_posts.each do |post|
write_post(post:, target_path:)
end
end
def write_recent_posts(target_path:, limit: RECENT_POSTS_LIMIT)
rows = ["# #{@site.title}", ""]
home_links.each do |link|
label = link.label&.strip
rows << if label.nil? || label.empty?
"=> #{link.href}"
else
"=> #{link.href} #{label}"
end
end
rows << "" unless home_links.empty?
rows << "## Recent posts"
rows << ""
@posts_by_year.recent_posts(limit).each do |post|
rows << post_link_line(post)
end
rows << ""
rows << "=> #{web_url_for("/")} Website"
rows << ""
file_path = File.join(target_path, "index.gmi")
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
end
def write_posts_index(target_path:)
rows = ["# #{@site.title} posts", "## Feed", ""]
@posts_by_year.all_posts.each do |post|
rows.concat(post_listing_lines(post))
end
rows << ""
rows << "=> / Home"
rows << "=> #{web_url_for("/posts/")} Read on the web"
rows << ""
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
private
def write_post(post:, target_path:)
rows = ["# #{post.title}", "", "#{post.formatted_date} by #{post.author}", ""]
if post.link_post?
rows << "=> #{post.link}"
rows << ""
end
gemtext_body = Utils::GemtextRenderer.render(post.markdown_body)
rows << gemtext_body unless gemtext_body.empty?
rows << "" unless rows.last.to_s.empty?
rows << "=> /posts Back to posts"
rows << "=> #{web_url_for("#{post.path}/")} Read on the web" if include_web_link?(post)
rows << ""
file_path = File.join(target_path, post.path.sub(%r{^/}, ""), "index.gmi")
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
end
def post_link_line(post)
"=> #{post.path}/ #{post.date.strftime("%Y-%m-%d")} - #{post.title}"
end
def post_listing_lines(post)
rows = [post_link_line(post)]
rows << "=> #{post.link}" if post.link_post?
rows
end
def include_web_link?(post)
markdown_without_fences = post.markdown_body.gsub(/```.*?```/m, "")
markdown_without_fences.match?(
%r{<\s*(?:a|p|div|span|ul|ol|li|audio|video|source|img|h[1-6]|blockquote|pre|code|table|tr|td|th|em|strong|br)\b}i
)
end
def web_url_for(path)
@site.url_for(path)
end
def home_links
@site.gemini_output_options&.home_links || []
end
end
end
end

View file

@ -12,6 +12,7 @@ module Pressa
attribute :link, Types::String.optional.default(nil) attribute :link, Types::String.optional.default(nil)
attribute :tags, Types::Array.of(Types::String).default([].freeze) attribute :tags, Types::Array.of(Types::String).default([].freeze)
attribute :body, Types::String attribute :body, Types::String
attribute :markdown_body, Types::String.default("".freeze)
attribute :excerpt, Types::String attribute :excerpt, Types::String
attribute :path, Types::String attribute :path, Types::String

View file

@ -1,12 +1,13 @@
require "pressa/plugin" require "pressa/plugin"
require "pressa/posts/repo" require "pressa/posts/repo"
require "pressa/posts/writer" require "pressa/posts/writer"
require "pressa/posts/gemini_writer"
require "pressa/posts/json_feed" require "pressa/posts/json_feed"
require "pressa/posts/rss_feed" require "pressa/posts/rss_feed"
module Pressa module Pressa
module Posts module Posts
class Plugin < Pressa::Plugin class BasePlugin < Pressa::Plugin
attr_reader :posts_by_year attr_reader :posts_by_year
def setup(site:, source_path:) def setup(site:, source_path:)
@ -16,7 +17,9 @@ module Pressa
repo = PostRepo.new repo = PostRepo.new
@posts_by_year = repo.read_posts(posts_dir) @posts_by_year = repo.read_posts(posts_dir)
end end
end
class HTMLPlugin < BasePlugin
def render(site:, target_path:) def render(site:, target_path:)
return unless @posts_by_year return unless @posts_by_year
@ -34,5 +37,24 @@ module Pressa
rss_feed.write_feed(target_path:, limit: 30) rss_feed.write_feed(target_path:, limit: 30)
end end
end end
class GeminiPlugin < BasePlugin
def render(site:, target_path:)
return unless @posts_by_year
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_posts_index(target_path:)
end
private
def gemini_recent_posts_limit(site)
site.gemini_output_options&.recent_posts_limit || GeminiWriter::RECENT_POSTS_LIMIT
end
end
Plugin = HTMLPlugin
end end
end end

View file

@ -48,6 +48,7 @@ module Pressa
link: metadata.link, link: metadata.link,
tags: metadata.tags, tags: metadata.tags,
body: html_body, body: html_body,
markdown_body: body_markdown,
excerpt:, excerpt:,
path: path:
) )

View file

@ -7,7 +7,7 @@ require "pressa/projects/models"
module Pressa module Pressa
module Projects module Projects
class Plugin < Pressa::Plugin class HTMLPlugin < Pressa::Plugin
attr_reader :scripts, :styles attr_reader :scripts, :styles
def initialize(projects: [], scripts: [], styles: []) def initialize(projects: [], scripts: [], styles: [])
@ -82,5 +82,57 @@ module Pressa
layout.call layout.call
end end
end end
class GeminiPlugin < Pressa::Plugin
def initialize(projects: [])
@projects = projects
end
def setup(site:, source_path:)
end
def render(site:, target_path:)
write_projects_index(site:, target_path:)
@projects.each do |project|
write_project_page(project:, site:, target_path:)
end
end
private
def write_projects_index(site:, target_path:)
rows = ["# Projects", ""]
@projects.each do |project|
rows << "## #{project.title}"
rows << project.description
rows << "=> #{project.url}"
rows << ""
end
rows << "=> / Home"
rows << "=> #{site.url_for("/projects/")} Read on the web"
rows << ""
file_path = File.join(target_path, "projects", "index.gmi")
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
end
def write_project_page(project:, site:, target_path:)
rows = [
"# #{project.title}",
"",
project.description,
"",
"=> #{project.url}",
"=> /projects/ Back to projects",
""
]
file_path = File.join(target_path, "projects", project.name, "index.gmi")
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
end
end
Plugin = HTMLPlugin
end end
end end

View file

@ -5,6 +5,13 @@ module Pressa
include Dry.Types() include Dry.Types()
end end
class OutputLink < Dry::Struct
# label is required for HTML remote links, but Gemini home_links may omit it.
attribute :label, Types::String.optional.default(nil)
attribute :href, Types::String
attribute :icon, Types::String.optional.default(nil)
end
class Script < Dry::Struct class Script < Dry::Struct
attribute :src, Types::String attribute :src, Types::String
attribute :defer, Types::Bool.default(true) attribute :defer, Types::Bool.default(true)
@ -14,18 +21,36 @@ module Pressa
attribute :href, Types::String attribute :href, Types::String
end end
class OutputOptions < Dry::Struct
attribute :public_excludes, Types::Array.of(Types::String).default([].freeze)
end
class HTMLOutputOptions < OutputOptions
attribute :remote_links, Types::Array.of(OutputLink).default([].freeze)
end
class GeminiOutputOptions < OutputOptions
attribute :recent_posts_limit, Types::Integer.default(20)
attribute :home_links, Types::Array.of(OutputLink).default([].freeze)
end
class Site < Dry::Struct class Site < Dry::Struct
OUTPUT_OPTIONS = Types.Instance(OutputOptions)
attribute :author, Types::String attribute :author, Types::String
attribute :email, Types::String attribute :email, Types::String
attribute :title, Types::String attribute :title, Types::String
attribute :description, Types::String attribute :description, Types::String
attribute :url, Types::String attribute :url, Types::String
attribute :fediverse_creator, Types::String.optional.default(nil)
attribute :image_url, Types::String.optional.default(nil) attribute :image_url, Types::String.optional.default(nil)
attribute :copyright_start_year, Types::Integer.optional.default(nil) attribute :copyright_start_year, Types::Integer.optional.default(nil)
attribute :scripts, Types::Array.of(Script).default([].freeze) attribute :scripts, Types::Array.of(Script).default([].freeze)
attribute :styles, Types::Array.of(Stylesheet).default([].freeze) attribute :styles, Types::Array.of(Stylesheet).default([].freeze)
attribute :plugins, Types::Array.default([].freeze) attribute :plugins, Types::Array.default([].freeze)
attribute :renderers, Types::Array.default([].freeze) attribute :renderers, Types::Array.default([].freeze)
attribute :output_format, Types::String.default("html".freeze).enum("html", "gemini")
attribute :output_options, OUTPUT_OPTIONS.default { HTMLOutputOptions.new }
def url_for(path) def url_for(path)
"#{url}#{path}" "#{url}#{path}"
@ -35,5 +60,17 @@ module Pressa
return nil unless image_url return nil unless image_url
"#{image_url}#{path}" "#{image_url}#{path}"
end end
def public_excludes
output_options.public_excludes
end
def html_output_options
output_options if output_options.is_a?(HTMLOutputOptions)
end
def gemini_output_options
output_options if output_options.is_a?(GeminiOutputOptions)
end
end end
end end

View file

@ -50,6 +50,7 @@ module Pressa
Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file| Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
next if File.directory?(source_file) next if File.directory?(source_file)
next if skip_file?(source_file) next if skip_file?(source_file)
next if skip_for_output_format?(source_file:, public_dir:)
filename = File.basename(source_file) filename = File.basename(source_file)
ext = File.extname(source_file)[1..] ext = File.extname(source_file)[1..]
@ -78,6 +79,7 @@ module Pressa
Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file| Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
next if File.directory?(source_file) next if File.directory?(source_file)
next if skip_file?(source_file) next if skip_file?(source_file)
next if skip_for_output_format?(source_file:, public_dir:)
filename = File.basename(source_file) filename = File.basename(source_file)
ext = File.extname(source_file)[1..] ext = File.extname(source_file)[1..]
@ -102,9 +104,31 @@ module Pressa
basename.start_with?(".") basename.start_with?(".")
end end
def skip_for_output_format?(source_file:, public_dir:)
relative_path = source_file.sub("#{public_dir}/", "")
site.public_excludes.any? do |pattern|
excluded_by_pattern?(relative_path:, pattern:)
end
end
def excluded_by_pattern?(relative_path:, pattern:)
normalized = pattern.sub(%r{\A/+}, "")
if normalized.end_with?("/**")
prefix = normalized.delete_suffix("/**")
return relative_path.start_with?("#{prefix}/") || relative_path == prefix
end
File.fnmatch?(normalized, relative_path, File::FNM_PATHNAME)
end
def site_with_copyright_start_year(base_site) def site_with_copyright_start_year(base_site)
start_year = find_copyright_start_year(base_site) start_year = find_copyright_start_year(base_site)
Site.new(**base_site.to_h.merge(copyright_start_year: start_year)) attrs = base_site.to_h.merge(
output_options: base_site.output_options,
copyright_start_year: start_year
)
Site.new(**attrs)
end end
def find_copyright_start_year(base_site) def find_copyright_start_year(base_site)

View file

@ -0,0 +1,67 @@
require "yaml"
require "pressa/utils/file_writer"
require "pressa/utils/gemtext_renderer"
module Pressa
module Utils
class GeminiMarkdownRenderer
def can_render_file?(filename:, extension:)
extension == "md"
end
def render(site:, file_path:, target_dir:)
content = File.read(file_path)
metadata, body_markdown = parse_content(content)
page_title = presence(metadata["Title"]) || File.basename(file_path, ".md").capitalize
show_extension = ["true", "yes", true].include?(metadata["Show extension"])
slug = File.basename(file_path, ".md")
relative_dir = File.dirname(file_path).sub(/^.*?\/public\/?/, "")
relative_dir = "" if relative_dir == "."
canonical_html_path = if show_extension
"/#{relative_dir}/#{slug}.html".squeeze("/")
else
"/#{relative_dir}/#{slug}/".squeeze("/")
end
rows = ["# #{page_title}", ""]
gemtext_body = GemtextRenderer.render(body_markdown)
rows << gemtext_body unless gemtext_body.empty?
rows << "" unless rows.last.to_s.empty?
rows << "=> #{site.url_for(canonical_html_path)} Read on the web"
rows << ""
output_filename = if show_extension
"#{slug}.gmi"
else
File.join(slug, "index.gmi")
end
output_path = File.join(target_dir, output_filename)
FileWriter.write(path: output_path, content: rows.join("\n"))
end
private
def parse_content(content)
if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)/m
yaml_content = Regexp.last_match(1)
markdown = Regexp.last_match(2)
metadata = YAML.safe_load(yaml_content) || {}
[metadata, markdown]
else
[{}, content]
end
end
def presence(value)
return value unless value.respond_to?(:strip)
stripped = value.strip
stripped.empty? ? nil : stripped
end
end
end
end

View file

@ -0,0 +1,257 @@
require "cgi"
module Pressa
module Utils
class GemtextRenderer
class << self
def render(markdown)
lines = markdown.to_s.gsub("\r\n", "\n").split("\n")
link_reference_definitions = extract_link_reference_definitions(lines)
output_lines = []
in_preformatted_block = false
lines.each do |line|
if line.start_with?("```")
output_lines << "```"
in_preformatted_block = !in_preformatted_block
next
end
if in_preformatted_block
output_lines << line
next
end
next if link_reference_definition?(line)
converted_lines = convert_line(line, link_reference_definitions)
output_lines.concat(converted_lines)
end
squish_blank_lines(output_lines).join("\n").strip
end
private
def convert_line(line, link_reference_definitions)
stripped = line.strip
return [""] if stripped.empty?
return convert_heading(stripped, link_reference_definitions) if heading_line?(stripped)
return convert_list_item(stripped, link_reference_definitions) if list_item_line?(stripped)
return convert_quote_line(stripped, link_reference_definitions) if quote_line?(stripped)
convert_text_line(line, link_reference_definitions)
end
def convert_heading(line, link_reference_definitions)
marker, text = line.split(/\s+/, 2)
heading_text, links = extract_links(text.to_s, link_reference_definitions)
rows = []
rows << "#{marker} #{clean_inline_text(heading_text)}".strip
rows.concat(render_link_rows(links))
rows
end
def convert_list_item(line, link_reference_definitions)
text = line.sub(/\A[-*+]\s+/, "")
if link_only_list_item?(text, link_reference_definitions)
_clean_text, links = extract_links(text, link_reference_definitions)
return render_link_rows(links)
end
clean_text, links = extract_links(text, link_reference_definitions)
rows = []
rows << "* #{clean_inline_text(clean_text)}".strip
rows.concat(render_link_rows(links))
rows
end
def convert_quote_line(line, link_reference_definitions)
text = line.sub(/\A>\s?/, "")
clean_text, links = extract_links(text, link_reference_definitions)
rows = []
rows << "> #{clean_inline_text(clean_text)}".strip
rows.concat(render_link_rows(links))
rows
end
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?
rows.concat(render_link_rows(links))
rows.empty? ? [""] : rows
end
def extract_links(text, link_reference_definitions)
links = []
work = text.dup
work.gsub!(%r{<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}i) do
url = Regexp.last_match(1)
label = clean_inline_text(strip_html_tags(Regexp.last_match(2)))
links << [url, label]
label
end
work.gsub!(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/) do
label = clean_inline_text(Regexp.last_match(1))
url = Regexp.last_match(2)
links << [url, label]
label
end
work.gsub!(/\[([^\]]+)\]\[([^\]]*)\]/) do
label_text = Regexp.last_match(1)
reference_key = Regexp.last_match(2)
reference_key = label_text if reference_key.strip.empty?
url = resolve_link_reference(link_reference_definitions, reference_key)
next Regexp.last_match(0) unless url
label = clean_inline_text(label_text)
links << [url, label]
label
end
work.scan(/(?:href|src)=["']([^"']+)["']/i) do |match|
url = match.first
next if links.any? { |(existing_url, _)| existing_url == url }
links << [url, fallback_label(url)]
end
[work, links]
end
def resolve_link_reference(link_reference_definitions, key)
link_reference_definitions[normalize_link_reference_key(key)]
end
def link_only_list_item?(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)
normalized_remaining = clean_inline_text(remaining_text)
return true if normalized_remaining.empty?
links_count = links.length
links_count == 1 && normalized_remaining.match?(/\A[\w@.+\-\/ ]+:\z/)
end
def extract_link_reference_definitions(lines)
links = {}
lines.each do |line|
match = line.match(/\A\s{0,3}\[([^\]]+)\]:\s*(\S+)/)
next unless match
key = normalize_link_reference_key(match[1])
value = match[2]
value = value[1..-2] if value.start_with?("<") && value.end_with?(">")
links[key] = value
end
links
end
def normalize_link_reference_key(key)
key.to_s.strip.downcase.gsub(/\s+/, " ")
end
def strip_links_from_text(text)
work = text.dup
work.gsub!(%r{<a\s+[^>]*href=["'][^"']+["'][^>]*>.*?</a>}i, "")
work.gsub!(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/, "")
work.gsub!(/\[([^\]]+)\]\[([^\]]*)\]/, "")
work
end
def render_link_rows(links)
links.filter_map do |url, label|
next nil if url.nil? || url.strip.empty?
"=> #{url}"
end
end
def clean_inline_text(text)
cleaned = text.to_s.dup
cleaned = strip_html_tags(cleaned)
cleaned.gsub!(/`([^`]+)`/, '\1')
cleaned.gsub!(/\*\*([^*]+)\*\*/, '\1')
cleaned.gsub!(/__([^_]+)__/, '\1')
cleaned.gsub!(/\*([^*]+)\*/, '\1')
cleaned.gsub!(/_([^_]+)_/, '\1')
cleaned.gsub!(/\s+/, " ")
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)
text.gsub(/<[^>]+>/, "")
end
def fallback_label(url)
uri_path = url.split("?").first
basename = File.basename(uri_path.to_s)
return url if basename.nil? || basename.empty? || basename == "/"
basename
end
def heading_line?(line)
line.match?(/\A\#{1,3}\s+/)
end
def list_item_line?(line)
line.match?(/\A[-*+]\s+/)
end
def quote_line?(line)
line.start_with?(">")
end
def link_reference_definition?(line)
line.match?(/\A\s{0,3}\[[^\]]+\]:\s+\S/)
end
def squish_blank_lines(lines)
output = []
previous_blank = false
lines.each do |line|
blank = line.strip.empty?
next if blank && previous_blank
output << line
previous_blank = blank
end
output
end
end
end
end
end

View file

@ -69,7 +69,7 @@ module Pressa
title: site.title title: site.title
) )
meta(name: "fediverse:creator", content: "@sjs@techhub.social") meta(name: "fediverse:creator", content: site.fediverse_creator) if site.fediverse_creator
link(rel: "author", type: "text/plain", href: site.url_for("/humans.txt")) link(rel: "author", type: "text/plain", href: site.url_for("/humans.txt"))
link(rel: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png")) link(rel: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png"))
link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon")) link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon"))
@ -125,7 +125,7 @@ module Pressa
h1 do h1 do
a(href: site.url) { site.title } a(href: site.url) { site.title }
end end
br
h4 do h4 do
plain "By " plain "By "
a(href: site.url_for("/about")) { site.author } a(href: site.url_for("/about")) { site.author }
@ -134,19 +134,19 @@ module Pressa
nav(class: "remote") do nav(class: "remote") do
ul do ul do
li(class: "mastodon") do remote_nav_links.each do |link|
a(rel: "me", "aria-label": "Mastodon", href: "https://techhub.social/@sjs") do li(class: remote_link_class(link)) do
raw(safe(Icons.mastodon)) attrs = {"aria-label": link.label, href: remote_link_href(link.href)}
end attrs[:rel] = "me" if mastodon_link?(link)
end
li(class: "github") do a(**attrs) do
a("aria-label": "GitHub", href: "https://github.com/samsonjs") do icon_markup = remote_link_icon_markup(link)
raw(safe(Icons.github)) if icon_markup
end raw(safe(icon_markup))
end else
li(class: "rss") do plain link.label
a("aria-label": "RSS", href: site.url_for("/feed.xml")) do end
raw(safe(Icons.rss)) end
end end
end end
end end
@ -177,6 +177,96 @@ module Pressa
attrs[:defer] = true if scr.defer attrs[:defer] = true if scr.defer
script(**attrs) script(**attrs)
end 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 links = document.querySelectorAll(
'header.primary nav.remote a[href^="gemini://"]'
);
if (!links || links.length === 0) return;
for (var i = 0; i < links.length; i++) {
(function (link) {
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;
});
})(links[i]);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", setupGeminiFallback);
} else {
setupGeminiFallback();
}
})();
JS
end
end end
def script_src(src) def script_src(src)
@ -203,6 +293,55 @@ module Pressa
"#{start_year} - #{current_year}" "#{start_year} - #{current_year}"
end end
def html_remote_links
site.html_output_options&.remote_links || []
end
def remote_nav_links
html_remote_links
end
def remote_link_href(href)
return href if href.match?(/\A[a-z][a-z0-9+\-.]*:/i)
absolute_asset(href)
end
def remote_link_class(link)
slug = link.icon || link.label.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
"remote-link #{slug}"
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
Icons.public_send(icon_renderer)
end
def remote_link_icon_renderer(icon)
case icon
when "mastodon" then :mastodon
when "github" then :github
when "rss" then :rss
when "code" then :code
end
end
def mastodon_link?(link)
link.icon == "mastodon"
end
end end
end end
end end

View file

@ -160,7 +160,7 @@ header.primary .title {
header.primary h1, header.primary h1,
header.primary h4 { header.primary h4 {
display: inline-block; display: block;
margin: 0; margin: 0;
padding: 0; padding: 0;
word-wrap: break-word; word-wrap: break-word;
@ -173,6 +173,7 @@ header.primary h4 {
header.primary h1 { header.primary h1 {
font-size: 1.3rem; font-size: 1.3rem;
line-height: 1.2; line-height: 1.2;
margin-bottom: 0.25rem;
} }
header.primary h1 a, header.primary h1 a,
@ -180,6 +181,8 @@ header.primary h1 a:visited {
color: #f7f7f7; color: #f7f7f7;
} }
/* (protocol-link styles removed; Gemini now renders in the remote icon row) */
header.primary h4 { header.primary h4 {
font-size: 0.8rem; font-size: 0.8rem;
line-height: 1.2; line-height: 1.2;
@ -264,6 +267,15 @@ header.primary nav ul li.github .icon {
color: #4183c4; 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 { footer {
padding: 0 env(safe-area-inset-right) 2rem env(safe-area-inset-left); padding: 0 env(safe-area-inset-right) 2rem env(safe-area-inset-left);
text-align: center; text-align: center;

View file

@ -3,6 +3,7 @@ email = "sami@samhuri.net"
title = "samhuri.net" title = "samhuri.net"
description = "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days." description = "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days."
url = "https://samhuri.net" url = "https://samhuri.net"
fediverse_creator = "@sjs@techhub.social"
image_url = "/images/me.jpg" image_url = "/images/me.jpg"
scripts = [] scripts = []
styles = ["/css/normalize.css", "/css/style.css", "/css/syntax.css"] styles = ["/css/normalize.css", "/css/style.css", "/css/syntax.css"]
@ -16,3 +17,35 @@ scripts = [
"/js/projects.js" "/js/projects.js"
] ]
styles = [] styles = []
[outputs.html]
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": "Gemini", "href": "gemini://samhuri.net", "icon": "gemini"}
]
[outputs.gemini]
recent_posts_limit = 20
home_links = [
"/about",
"/posts",
"/projects",
"https://techhub.social/@sjs",
"https://github.com/samsonjs",
"/posts/feed.gmi",
"https://samhuri.net/feed.xml",
"mailto:sami@samhuri.net"
]
exclude_public = [
"tweets/**",
"css/**",
"js/**",
"apple-touch-icon.png",
"favicon.ico",
"favicon.gif",
"robots.txt",
"humans.txt",
"isitmycakeday.html"
]

View file

@ -12,6 +12,7 @@ class Pressa::Config::LoaderTest < Minitest::Test
assert_equal("https://samhuri.net", site.url) assert_equal("https://samhuri.net", site.url)
assert_equal("https://samhuri.net/images/me.jpg", site.image_url) assert_equal("https://samhuri.net/images/me.jpg", site.image_url)
assert_equal(["/css/style.css"], site.styles.map(&:href)) assert_equal(["/css/style.css"], site.styles.map(&:href))
assert_equal(["Mastodon", "GitHub"], site.html_output_options&.remote_links&.map(&:label))
projects_plugin = site.plugins.find { |plugin| plugin.is_a?(Pressa::Projects::Plugin) } projects_plugin = site.plugins.find { |plugin| plugin.is_a?(Pressa::Projects::Plugin) }
refute_nil(projects_plugin) refute_nil(projects_plugin)
@ -29,6 +30,209 @@ class Pressa::Config::LoaderTest < Minitest::Test
end end
end end
def test_build_site_supports_gemini_output_format
with_temp_config do |dir|
loader = Pressa::Config::Loader.new(source_path: dir)
site = loader.build_site(output_format: "gemini")
assert_equal("gemini", site.output_format)
assert(site.plugins.any? { |plugin| plugin.is_a?(Pressa::Posts::GeminiPlugin) })
assert(site.plugins.any? { |plugin| plugin.is_a?(Pressa::Projects::GeminiPlugin) })
assert_equal(["Pressa::Utils::GeminiMarkdownRenderer"], site.renderers.map(&:class).map(&:name))
assert_equal(["tweets/**"], site.public_excludes)
assert_equal(20, site.gemini_output_options&.recent_posts_limit)
assert_equal(["/about/", "https://github.com/samsonjs"], site.gemini_output_options&.home_links&.map(&:href))
end
end
def test_build_site_rejects_invalid_output_excludes
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
[outputs.gemini]
exclude_public = [123]
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
error = assert_raises(Pressa::Config::ValidationError) do
loader.build_site(output_format: "gemini")
end
assert_match(/exclude_public\[0\] to be a non-empty String/, error.message)
end
end
def test_build_site_rejects_legacy_output_key
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
[output.gemini]
exclude_public = ["tweets/**"]
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
assert_match(/Legacy key 'output' is no longer supported/, error.message)
end
end
def test_build_site_rejects_legacy_social_url_keys
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
mastodon_url = "https://example.social/@sami"
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
assert_match(/Legacy keys 'mastodon_url'\/'github_url' are no longer supported/, error.message)
end
end
def test_build_site_accepts_gemini_home_links
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
plugins = ["posts"]
[outputs.gemini]
recent_posts_limit = 15
home_links = [
{"label": "About", "href": "/about/"},
{"label": "GitHub", "href": "https://github.com/samsonjs"}
]
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
site = loader.build_site(output_format: "gemini")
assert_equal(15, site.gemini_output_options&.recent_posts_limit)
assert_equal(["About", "GitHub"], site.gemini_output_options&.home_links&.map(&:label))
assert_equal(["/about/", "https://github.com/samsonjs"], site.gemini_output_options&.home_links&.map(&:href))
end
end
def test_build_site_rejects_invalid_gemini_home_links
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
[outputs.gemini]
home_links = [{"label": "About"}]
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
assert_match(/outputs\.gemini\.home_links\[0\]\.href to be a non-empty String/, error.message)
end
end
def test_build_site_rejects_invalid_recent_posts_limit
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
[outputs.gemini]
recent_posts_limit = 0
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
assert_match(/outputs\.gemini\.recent_posts_limit to be a positive Integer/, error.message)
end
end
def test_build_site_rejects_invalid_html_remote_links
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
[outputs.html]
remote_links = [{"label": "GitHub", "href": "github.com/samsonjs"}]
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
assert_match(/outputs\.html\.remote_links\[0\]\.href to start with \//, error.message)
end
end
def test_build_site_rejects_unknown_gemini_output_keys
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
[outputs.gemini]
something_else = true
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
assert_match(/Unknown key\(s\) in site\.toml outputs\.gemini: something_else/, error.message)
end
end
def test_build_site_rejects_unknown_gemini_home_link_keys
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
[outputs.gemini]
home_links = [{"label": "About", "href": "/about/", "icon": "mastodon"}]
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
assert_match(/Unknown key\(s\) in site\.toml outputs\.gemini\.home_links\[0\]: icon/, error.message)
end
end
def test_build_site_raises_a_validation_error_for_missing_required_site_keys def test_build_site_raises_a_validation_error_for_missing_required_site_keys
Dir.mktmpdir do |dir| Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), "title = \"x\"\n") File.write(File.join(dir, "site.toml"), "title = \"x\"\n")
@ -357,6 +561,35 @@ class Pressa::Config::LoaderTest < Minitest::Test
end end
end end
def test_build_site_allows_string_home_links_and_optional_labels_for_gemini
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
[outputs.gemini]
home_links = [
"/about/",
{"href": "/posts/"},
{"label": "GitHub", "href": "https://github.com/samsonjs"}
]
TOML
File.write(File.join(dir, "projects.toml"), "projects = []\n")
loader = Pressa::Config::Loader.new(source_path: dir)
site = loader.build_site(output_format: "gemini")
assert_equal("gemini", site.output_format)
assert_equal(3, site.gemini_output_options&.home_links&.length)
assert_nil(site.gemini_output_options&.home_links&.at(0)&.label)
assert_nil(site.gemini_output_options&.home_links&.at(1)&.label)
assert_equal("GitHub", site.gemini_output_options&.home_links&.at(2)&.label)
end
end
private private
def with_temp_config def with_temp_config
@ -375,6 +608,17 @@ class Pressa::Config::LoaderTest < Minitest::Test
[projects_plugin] [projects_plugin]
scripts = ["/js/projects.js"] scripts = ["/js/projects.js"]
styles = [] styles = []
[outputs.html]
remote_links = [
{"label": "Mastodon", "href": "https://techhub.social/@sjs", "icon": "mastodon"},
{"label": "GitHub", "href": "https://github.com/samsonjs", "icon": "github"}
]
[outputs.gemini]
recent_posts_limit = 20
home_links = ["/about/", "https://github.com/samsonjs"]
exclude_public = ["tweets/**"]
TOML TOML
File.write(File.join(dir, "projects.toml"), <<~TOML) File.write(File.join(dir, "projects.toml"), <<~TOML)

View file

@ -0,0 +1,105 @@
require "test_helper"
require "fileutils"
require "tmpdir"
class Pressa::Posts::GeminiPluginTest < Minitest::Test
def site
@site ||= Pressa::Site.new(
author: "Sami Samhuri",
email: "sami@samhuri.net",
title: "samhuri.net",
description: "blog",
url: "https://samhuri.net",
output_format: "gemini",
output_options: Pressa::GeminiOutputOptions.new(
home_links: [
Pressa::OutputLink.new(label: "About", href: "/about/"),
Pressa::OutputLink.new(label: "Mastodon", href: "https://techhub.social/@sjs"),
Pressa::OutputLink.new(label: "GitHub", href: "https://github.com/samsonjs"),
Pressa::OutputLink.new(label: "Email", href: "mailto:sami@samhuri.net")
]
)
)
end
def test_render_writes_gemini_indexes_and_posts
Dir.mktmpdir do |root|
source_path = File.join(root, "source")
target_path = File.join(root, "target")
posts_dir = File.join(source_path, "posts", "2025", "11")
FileUtils.mkdir_p(posts_dir)
File.write(File.join(posts_dir, "markdown-only.md"), <<~MARKDOWN)
---
Title: Markdown Only
Author: Sami Samhuri
Date: 5th November, 2025
Timestamp: 2025-11-05T10:00:00-08:00
---
This post has [one link](https://example.com) and no raw HTML.
MARKDOWN
File.write(File.join(posts_dir, "html-heavy.md"), <<~MARKDOWN)
---
Title: HTML Heavy
Author: Sami Samhuri
Date: 6th November, 2025
Timestamp: 2025-11-06T10:00:00-08:00
---
<p>Raw HTML with <a href="https://example.org">a link</a>.</p>
MARKDOWN
File.write(File.join(posts_dir, "link-post.md"), <<~MARKDOWN)
---
Title: Link Post
Author: Sami Samhuri
Date: 7th November, 2025
Timestamp: 2025-11-07T10:00:00-08:00
Link: https://example.net/story
---
I wrote a short blurb about this interesting link.
MARKDOWN
plugin = Pressa::Posts::GeminiPlugin.new
plugin.setup(site:, source_path:)
plugin.render(site:, target_path:)
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/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")
assert(File.exist?(markdown_post))
assert(File.exist?(html_post))
index_text = File.read(File.join(target_path, "index.gmi"))
markdown_text = File.read(markdown_post)
html_text = File.read(html_post)
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")
assert_includes(index_text, "=> https://github.com/samsonjs")
assert_includes(index_text, "=> mailto:sami@samhuri.net")
refute_includes(markdown_text, "Read on the web")
assert_includes(html_text, "Read on the web")
assert_includes(markdown_text, "=> https://example.com")
assert_includes(html_text, "=> https://example.org")
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

@ -52,4 +52,40 @@ class Pressa::Projects::PluginTest < Minitest::Test
assert_includes(details_html, "css/projects.css") assert_includes(details_html, "css/projects.css")
end end
end end
def test_gemini_plugin_renders_index_and_project_pages
gemini_site = Pressa::Site.new(
author: "Sami Samhuri",
email: "sami@samhuri.net",
title: "samhuri.net",
description: "blog",
url: "https://samhuri.net",
output_format: "gemini",
output_options: Pressa::GeminiOutputOptions.new
)
plugin = Pressa::Projects::GeminiPlugin.new(projects: [project])
Dir.mktmpdir do |dir|
plugin.render(site: gemini_site, target_path: dir)
index_path = File.join(dir, "projects/index.gmi")
details_path = File.join(dir, "projects/demo/index.gmi")
assert(File.exist?(index_path))
assert(File.exist?(details_path))
index_text = File.read(index_path)
details_text = File.read(details_path)
assert_includes(index_text, "## Demo")
assert_includes(index_text, "Demo project")
assert_includes(index_text, "=> https://github.com/samsonjs/demo")
refute_includes(index_text, "=> /projects/demo/")
assert_includes(details_text, "=> https://github.com/samsonjs/demo")
assert_includes(details_text, "=> /projects/ Back to projects")
refute_includes(details_text, "Project link")
refute_includes(details_text, "Read on the web")
end
end
end end

View file

@ -65,6 +65,20 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test
) )
end end
def build_gemini_site(plugin:, renderer:, public_excludes: [])
Pressa::Site.new(
author: "Sami Samhuri",
email: "sami@samhuri.net",
title: "samhuri.net",
description: "blog",
url: "https://samhuri.net",
plugins: [plugin],
renderers: [renderer],
output_format: "gemini",
output_options: Pressa::GeminiOutputOptions.new(public_excludes:)
)
end
def build_posts_by_year(year:) def build_posts_by_year(year:)
post = Pressa::Posts::Post.new( post = Pressa::Posts::Post.new(
slug: "first-post", slug: "first-post",
@ -161,4 +175,24 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test
assert_equal(2006, plugin.render_site_year) assert_equal(2006, plugin.render_site_year)
end end
end end
def test_generate_skips_tweets_directory_for_gemini_output
Dir.mktmpdir do |root|
source_path = File.join(root, "source")
target_path = File.join(root, "target")
tweets_dir = File.join(source_path, "public", "tweets")
FileUtils.mkdir_p(tweets_dir)
File.write(File.join(tweets_dir, "index.html"), "<html>tweets</html>")
File.write(File.join(source_path, "public", "notes.md"), "# notes")
plugin = PluginSpy.new
renderer = MarkdownRendererSpy.new
site = build_gemini_site(plugin:, renderer:, public_excludes: ["tweets/**"])
Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:)
refute(File.exist?(File.join(target_path, "tweets", "index.html")))
assert(File.exist?(File.join(target_path, "notes.html")))
end
end
end end

View file

@ -28,6 +28,42 @@ class Pressa::SiteTest < Minitest::Test
assert_nil(site.image_url_for("/avatar.png")) assert_nil(site.image_url_for("/avatar.png"))
end end
def test_site_defaults_to_html_output_options
site = Pressa::Site.new(
author: "Sami Samhuri",
email: "sami@samhuri.net",
title: "samhuri.net",
description: "blog",
url: "https://samhuri.net"
)
assert_equal("html", site.output_format)
assert_instance_of(Pressa::HTMLOutputOptions, site.output_options)
assert_equal([], site.html_output_options&.remote_links)
assert_nil(site.gemini_output_options)
end
def test_output_option_helpers_match_gemini_site
site = Pressa::Site.new(
author: "Sami Samhuri",
email: "sami@samhuri.net",
title: "samhuri.net",
description: "blog",
url: "https://samhuri.net",
output_format: "gemini",
output_options: Pressa::GeminiOutputOptions.new(
public_excludes: ["tweets/**"],
recent_posts_limit: 12,
home_links: [Pressa::OutputLink.new(label: "About", href: "/about/")]
)
)
assert_nil(site.html_output_options)
assert_instance_of(Pressa::GeminiOutputOptions, site.gemini_output_options)
assert_equal(["tweets/**"], site.public_excludes)
assert_equal(12, site.gemini_output_options&.recent_posts_limit)
end
def test_create_site_builds_site_using_loader def test_create_site_builds_site_using_loader
Dir.mktmpdir do |dir| Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML) File.write(File.join(dir, "site.toml"), <<~TOML)
@ -49,4 +85,22 @@ class Pressa::SiteTest < Minitest::Test
assert_equal("https://beta.samhuri.net", site.url) assert_equal("https://beta.samhuri.net", site.url)
end end
end end
def test_create_site_accepts_output_format
Dir.mktmpdir do |dir|
File.write(File.join(dir, "site.toml"), <<~TOML)
author = "Sami Samhuri"
email = "sami@samhuri.net"
title = "samhuri.net"
description = "blog"
url = "https://samhuri.net"
TOML
File.write(File.join(dir, "projects.toml"), <<~TOML)
projects = []
TOML
site = Pressa.create_site(source_path: dir, output_format: "gemini")
assert_equal("gemini", site.output_format)
end
end
end end

View file

@ -0,0 +1,72 @@
require "test_helper"
class Pressa::Utils::GemtextRendererTest < Minitest::Test
def test_render_resolves_reference_style_links
markdown = <<~MARKDOWN
I'm in [Victoria, BC][vic] and on [GitHub][].
[vic]: https://example.com/victoria
[github]: https://github.com/samsonjs
MARKDOWN
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
assert_includes(rendered, "I'm in Victoria, BC and on GitHub.")
assert_includes(rendered, "=> https://example.com/victoria")
assert_includes(rendered, "=> https://github.com/samsonjs")
refute_includes(rendered, "[vic]")
refute_includes(rendered, "[GitHub][]")
end
def test_render_keeps_unresolved_reference_link_text
markdown = "Read [this][missing] please."
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
assert_includes(rendered, "Read [this][missing] please.")
refute_includes(rendered, "=> ")
end
def test_render_collapses_link_only_list_items_to_links
markdown = <<~MARKDOWN
## Where you can find me
- GitHub: [samsonjs][gh]
- [Stack Overflow][so]
- Mastodon: [@sjs@techhub.social][mastodon]
- Email: [sami@samhuri.net][email]
[gh]: https://github.com/samsonjs
[so]: https://stackoverflow.com/users/188752/sami-samhuri
[mastodon]: https://techhub.social/@sjs
[email]: mailto:sami@samhuri.net
MARKDOWN
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
assert_includes(rendered, "=> https://github.com/samsonjs")
assert_includes(rendered, "=> https://stackoverflow.com/users/188752/sami-samhuri")
assert_includes(rendered, "=> https://techhub.social/@sjs")
assert_includes(rendered, "=> mailto:sami@samhuri.net")
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

@ -32,6 +32,17 @@ class Pressa::Views::LayoutTest < Minitest::Test
) )
end end
def site_with_remote_links(links)
Pressa::Site.new(
author: "Sami Samhuri",
email: "sami@samhuri.net",
title: "samhuri.net",
description: "blog",
url: "https://samhuri.net",
output_options: Pressa::HTMLOutputOptions.new(remote_links: links)
)
end
def test_rendering_child_components_as_html_instead_of_escaped_text def test_rendering_child_components_as_html_instead_of_escaped_text
html = Pressa::Views::Layout.new( html = Pressa::Views::Layout.new(
site:, site:,
@ -96,4 +107,37 @@ class Pressa::Views::LayoutTest < Minitest::Test
assert_includes(html, "<footer>© #{current_year} <a href=\"https://samhuri.net/about\">Sami Samhuri</a></footer>") assert_includes(html, "<footer>© #{current_year} <a href=\"https://samhuri.net/about\">Sami Samhuri</a></footer>")
refute_includes(html, "<footer>© #{current_year} - #{current_year} ") refute_includes(html, "<footer>© #{current_year} - #{current_year} ")
end end
def test_remote_links_render_from_output_config
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")
]),
canonical_url: "https://samhuri.net/posts/",
content: content_view
).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
def test_missing_remote_links_do_not_render_hardcoded_profiles
html = Pressa::Views::Layout.new(
site:,
canonical_url: "https://samhuri.net/posts/",
content: content_view
).call
refute_includes(html, "techhub.social")
refute_includes(html, "github.com/samsonjs")
end
end end