diff --git a/.gitignore b/.gitignore index 84c0cbd..3cd5a3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ www +gemini Tests/*/actual diff --git a/AGENTS.md b/AGENTS.md index 23ee274..372ec69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Repository Guidelines ## 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`) - 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` - Static and renderable public content: `public/` - 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/`. ## 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. - `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 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 publish_beta|publish`: build and rsync `www/` to remote host. -- `rbenv exec bundle exec bake clean`: remove `www/`. +- `rbenv exec bundle exec bake mudge|beta|release`: build HTML with environment-specific base URLs. +- `rbenv exec bundle exec bake gemini`: build Gemini capsule into `gemini/`. +- `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 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 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). @@ -49,7 +56,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`. - 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. - 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 - 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 is defined in `bake.rb` via rsync over SSH. - Current publish host is `mudge` with: - - production: `/var/www/samhuri.net/public` - - beta: `/var/www/beta.samhuri.net/public` -- Validate `www/` before publishing to avoid shipping stale assets. + - production HTML: `/var/www/samhuri.net/public` + - beta HTML: `/var/www/beta.samhuri.net/public` + - 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. diff --git a/Readme.md b/Readme.md index e262e04..eac3f6a 100644 --- a/Readme.md +++ b/Readme.md @@ -13,7 +13,7 @@ If what you want is an artisanal, hand-crafted, static site generator for your p - Tests: `test/` - Config: `site.toml` and `projects.toml` - Content: `posts/` and `public/` -- Output: `www/` +- Output: `www/` (HTML), `gemini/` (Gemini capsule) ## 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.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]]`. `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. - 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 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`. - 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 beta bake release +bake gemini bake watch target=debug bake clean bake publish_beta +bake publish_gemini bake publish ``` @@ -109,3 +114,4 @@ bake lint_fix - Deployment uses `rsync` to host `mudge` (configured in `bake.rb`): - production: `/var/www/samhuri.net/public` - beta: `/var/www/beta.samhuri.net/public` + - gemini: `/var/gemini/samhuri.net` diff --git a/bake.rb b/bake.rb index be19b18..fb9babc 100644 --- a/bake.rb +++ b/bake.rb @@ -12,28 +12,34 @@ DRAFTS_DIR = "public/drafts".freeze PUBLISH_HOST = "mudge".freeze PRODUCTION_PUBLISH_DIR = "/var/www/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 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) def debug - build("http://localhost:8000") + build("http://localhost:8000", output_format: "html", target_path: "www") end # Generate the site for the mudge development server def mudge - build("http://mudge:8000") + build("http://mudge:8000", output_format: "html", target_path: "www") end # Generate the site for beta/staging def beta - build("https://beta.samhuri.net") + build("https://beta.samhuri.net", output_format: "html", target_path: "www") end # Generate the site for production 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 # Start local development server @@ -109,7 +115,7 @@ def publish_draft(input_path = nil) end # 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") unless command_available?("inotifywait") 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) 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 def publish release run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true) + publish_gemini end # Clean generated files def clean FileUtils.rm_rf("www") - puts "Cleaned www/ directory" + FileUtils.rm_rf("gemini") + puts "Cleaned www/ and gemini/ directories" end # Default task: run coverage and lint. @@ -358,16 +372,18 @@ def capture_command_optional(*command, chdir: Dir.pwd) "" end -# Build the site with specified URL -# @parameter url [String] The site URL to use -def build(url) +# Build the site with specified URL and output format. +# @parameter url [String] The site URL to use. +# @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" - puts "Building site for #{url}..." - site = Pressa.create_site(source_path: ".", url_override: url) + puts "Building #{output_format} site for #{url}..." + site = Pressa.create_site(source_path: ".", url_override: url, output_format:) generator = Pressa::SiteGenerator.new(site:) - generator.generate(source_path: ".", target_path: "www") - puts "Site built successfully in www/" + generator.generate(source_path: ".", target_path:) + puts "Site built successfully in #{target_path}/" end def run_build_target(target) diff --git a/lib/pressa.rb b/lib/pressa.rb index 958d59c..7d3ad7e 100644 --- a/lib/pressa.rb +++ b/lib/pressa.rb @@ -4,11 +4,12 @@ require "pressa/plugin" require "pressa/posts/plugin" require "pressa/projects/plugin" require "pressa/utils/markdown_renderer" +require "pressa/utils/gemini_markdown_renderer" require "pressa/config/loader" 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.build_site(url_override:) + loader.build_site(url_override:, output_format:) end end diff --git a/lib/pressa/config/loader.rb b/lib/pressa/config/loader.rb index bf11540..d999d67 100644 --- a/lib/pressa/config/loader.rb +++ b/lib/pressa/config/loader.rb @@ -2,6 +2,7 @@ require "pressa/site" require "pressa/posts/plugin" require "pressa/projects/plugin" require "pressa/utils/markdown_renderer" +require "pressa/utils/gemini_markdown_renderer" require "pressa/config/simple_toml" module Pressa @@ -16,13 +17,16 @@ module Pressa @source_path = source_path end - def build_site(url_override: nil) + def build_site(url_override: nil, output_format: "html") site_config = load_toml("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"] - 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( author: site_config["author"], @@ -30,13 +34,17 @@ module Pressa title: site_config["title"], description: site_config["description"], 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), scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"), styles: build_styles(site_config["styles"], context: "site.toml styles"), plugins:, - renderers: [ - Utils::MarkdownRenderer.new - ] + renderers: build_renderers(output_format: normalized_output_format), + output_format: normalized_output_format, + output_options: ) end @@ -80,21 +88,53 @@ module Pressa raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}" 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.map.with_index do |plugin_name, index| case plugin_name when "posts" - Posts::Plugin.new + posts_plugin_for(output_format) when "projects" - build_projects_plugin(site_config) + build_projects_plugin(site_config, output_format:) else raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]" 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) return [] if value.nil? raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array) @@ -116,16 +156,23 @@ module Pressa 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_config = load_toml("projects.toml") projects = build_projects(projects_config) - Projects::Plugin.new( - 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") - ) + case output_format + when "html" + Projects::HTMLPlugin.new( + 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 def hash_or_empty(value, context) @@ -135,6 +182,78 @@ module Pressa raise ValidationError, "Expected #{context} to be a table" 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:) entries = array_or_empty(value, context) @@ -212,6 +331,110 @@ module Pressa raise ValidationError, "Expected #{context} to start with / or use http(s) scheme" 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 diff --git a/lib/pressa/posts/gemini_writer.rb b/lib/pressa/posts/gemini_writer.rb new file mode 100644 index 0000000..2b4742e --- /dev/null +++ b/lib/pressa/posts/gemini_writer.rb @@ -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 diff --git a/lib/pressa/posts/models.rb b/lib/pressa/posts/models.rb index d95d63e..aaf70cd 100644 --- a/lib/pressa/posts/models.rb +++ b/lib/pressa/posts/models.rb @@ -12,6 +12,7 @@ module Pressa attribute :link, Types::String.optional.default(nil) attribute :tags, Types::Array.of(Types::String).default([].freeze) attribute :body, Types::String + attribute :markdown_body, Types::String.default("".freeze) attribute :excerpt, Types::String attribute :path, Types::String diff --git a/lib/pressa/posts/plugin.rb b/lib/pressa/posts/plugin.rb index f12db01..c0c3f14 100644 --- a/lib/pressa/posts/plugin.rb +++ b/lib/pressa/posts/plugin.rb @@ -1,12 +1,13 @@ require "pressa/plugin" require "pressa/posts/repo" require "pressa/posts/writer" +require "pressa/posts/gemini_writer" require "pressa/posts/json_feed" require "pressa/posts/rss_feed" module Pressa module Posts - class Plugin < Pressa::Plugin + class BasePlugin < Pressa::Plugin attr_reader :posts_by_year def setup(site:, source_path:) @@ -16,7 +17,9 @@ module Pressa repo = PostRepo.new @posts_by_year = repo.read_posts(posts_dir) end + end + class HTMLPlugin < BasePlugin def render(site:, target_path:) return unless @posts_by_year @@ -34,5 +37,24 @@ module Pressa rss_feed.write_feed(target_path:, limit: 30) 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 diff --git a/lib/pressa/posts/repo.rb b/lib/pressa/posts/repo.rb index a036556..bc167f3 100644 --- a/lib/pressa/posts/repo.rb +++ b/lib/pressa/posts/repo.rb @@ -48,6 +48,7 @@ module Pressa link: metadata.link, tags: metadata.tags, body: html_body, + markdown_body: body_markdown, excerpt:, path: ) diff --git a/lib/pressa/projects/plugin.rb b/lib/pressa/projects/plugin.rb index cb1a611..6686cd9 100644 --- a/lib/pressa/projects/plugin.rb +++ b/lib/pressa/projects/plugin.rb @@ -7,7 +7,7 @@ require "pressa/projects/models" module Pressa module Projects - class Plugin < Pressa::Plugin + class HTMLPlugin < Pressa::Plugin attr_reader :scripts, :styles def initialize(projects: [], scripts: [], styles: []) @@ -82,5 +82,57 @@ module Pressa layout.call 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 diff --git a/lib/pressa/site.rb b/lib/pressa/site.rb index 99899d4..2e81332 100644 --- a/lib/pressa/site.rb +++ b/lib/pressa/site.rb @@ -5,6 +5,13 @@ module Pressa include Dry.Types() 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 attribute :src, Types::String attribute :defer, Types::Bool.default(true) @@ -14,18 +21,36 @@ module Pressa attribute :href, Types::String 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 + OUTPUT_OPTIONS = Types.Instance(OutputOptions) + attribute :author, Types::String attribute :email, Types::String attribute :title, Types::String attribute :description, Types::String attribute :url, Types::String + attribute :fediverse_creator, Types::String.optional.default(nil) attribute :image_url, Types::String.optional.default(nil) attribute :copyright_start_year, Types::Integer.optional.default(nil) attribute :scripts, Types::Array.of(Script).default([].freeze) attribute :styles, Types::Array.of(Stylesheet).default([].freeze) attribute :plugins, 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) "#{url}#{path}" @@ -35,5 +60,17 @@ module Pressa return nil unless image_url "#{image_url}#{path}" 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 diff --git a/lib/pressa/site_generator.rb b/lib/pressa/site_generator.rb index bc7aa6b..dbf9a64 100644 --- a/lib/pressa/site_generator.rb +++ b/lib/pressa/site_generator.rb @@ -50,6 +50,7 @@ module Pressa Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file| next if File.directory?(source_file) next if skip_file?(source_file) + next if skip_for_output_format?(source_file:, public_dir:) filename = File.basename(source_file) 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| next if File.directory?(source_file) next if skip_file?(source_file) + next if skip_for_output_format?(source_file:, public_dir:) filename = File.basename(source_file) ext = File.extname(source_file)[1..] @@ -102,9 +104,31 @@ module Pressa basename.start_with?(".") 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) 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 def find_copyright_start_year(base_site) diff --git a/lib/pressa/utils/gemini_markdown_renderer.rb b/lib/pressa/utils/gemini_markdown_renderer.rb new file mode 100644 index 0000000..0d954e2 --- /dev/null +++ b/lib/pressa/utils/gemini_markdown_renderer.rb @@ -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 diff --git a/lib/pressa/utils/gemtext_renderer.rb b/lib/pressa/utils/gemtext_renderer.rb new file mode 100644 index 0000000..518dd56 --- /dev/null +++ b/lib/pressa/utils/gemtext_renderer.rb @@ -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{]*href=["']([^"']+)["'][^>]*>(.*?)}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{]*href=["'][^"']+["'][^>]*>.*?}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 diff --git a/lib/pressa/views/layout.rb b/lib/pressa/views/layout.rb index 28dddcd..4fcb5b8 100644 --- a/lib/pressa/views/layout.rb +++ b/lib/pressa/views/layout.rb @@ -69,7 +69,7 @@ module Pressa 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: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png")) link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon")) @@ -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,19 +134,19 @@ module Pressa nav(class: "remote") do ul do - li(class: "mastodon") do - a(rel: "me", "aria-label": "Mastodon", href: "https://techhub.social/@sjs") do - raw(safe(Icons.mastodon)) - end - end - li(class: "github") do - a("aria-label": "GitHub", href: "https://github.com/samsonjs") do - raw(safe(Icons.github)) - end - end - li(class: "rss") do - a("aria-label": "RSS", href: site.url_for("/feed.xml")) do - raw(safe(Icons.rss)) + 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) + + a(**attrs) do + icon_markup = remote_link_icon_markup(link) + if icon_markup + raw(safe(icon_markup)) + else + plain link.label + end + end end end end @@ -177,6 +177,96 @@ 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 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 def script_src(src) @@ -203,6 +293,55 @@ module Pressa "#{start_year} - #{current_year}" 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 + 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 diff --git a/public/css/style.css b/public/css/style.css index 47ec4a9..df42456 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -160,7 +160,7 @@ header.primary .title { header.primary h1, header.primary h4 { - display: inline-block; + display: block; margin: 0; padding: 0; word-wrap: break-word; @@ -173,6 +173,7 @@ header.primary h4 { header.primary h1 { font-size: 1.3rem; line-height: 1.2; + margin-bottom: 0.25rem; } header.primary h1 a, @@ -180,6 +181,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 +267,15 @@ header.primary nav ul li.github .icon { color: #4183c4; } +header.primary nav ul li.gemini a, +header.primary nav ul li.gemini a:visited { + color: #bdbdbd; +} + +header.primary nav ul li.gemini a:hover { + color: #f7f7f7; +} + footer { padding: 0 env(safe-area-inset-right) 2rem env(safe-area-inset-left); text-align: center; diff --git a/site.toml b/site.toml index 6c906e3..01ececa 100644 --- a/site.toml +++ b/site.toml @@ -3,6 +3,7 @@ email = "sami@samhuri.net" title = "samhuri.net" description = "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days." url = "https://samhuri.net" +fediverse_creator = "@sjs@techhub.social" image_url = "/images/me.jpg" scripts = [] styles = ["/css/normalize.css", "/css/style.css", "/css/syntax.css"] @@ -16,3 +17,35 @@ scripts = [ "/js/projects.js" ] 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" +] diff --git a/test/config/loader_test.rb b/test/config/loader_test.rb index 4a4df45..86d6148 100644 --- a/test/config/loader_test.rb +++ b/test/config/loader_test.rb @@ -12,6 +12,7 @@ class Pressa::Config::LoaderTest < Minitest::Test assert_equal("https://samhuri.net", site.url) assert_equal("https://samhuri.net/images/me.jpg", site.image_url) 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) } refute_nil(projects_plugin) @@ -29,6 +30,209 @@ class Pressa::Config::LoaderTest < Minitest::Test 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 Dir.mktmpdir do |dir| File.write(File.join(dir, "site.toml"), "title = \"x\"\n") @@ -357,6 +561,35 @@ class Pressa::Config::LoaderTest < Minitest::Test 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 def with_temp_config @@ -375,6 +608,17 @@ class Pressa::Config::LoaderTest < Minitest::Test [projects_plugin] scripts = ["/js/projects.js"] 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 File.write(File.join(dir, "projects.toml"), <<~TOML) diff --git a/test/posts/gemini_plugin_test.rb b/test/posts/gemini_plugin_test.rb new file mode 100644 index 0000000..5bc9fab --- /dev/null +++ b/test/posts/gemini_plugin_test.rb @@ -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 + --- + +

Raw HTML with a link.

+ 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 diff --git a/test/projects/plugin_test.rb b/test/projects/plugin_test.rb index 708f241..3886ba4 100644 --- a/test/projects/plugin_test.rb +++ b/test/projects/plugin_test.rb @@ -52,4 +52,40 @@ class Pressa::Projects::PluginTest < Minitest::Test assert_includes(details_html, "css/projects.css") 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 diff --git a/test/site_generator_rendering_test.rb b/test/site_generator_rendering_test.rb index 14af007..3fcba23 100644 --- a/test/site_generator_rendering_test.rb +++ b/test/site_generator_rendering_test.rb @@ -65,6 +65,20 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test ) 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:) post = Pressa::Posts::Post.new( slug: "first-post", @@ -161,4 +175,24 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test assert_equal(2006, plugin.render_site_year) 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"), "tweets") + 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 diff --git a/test/site_test.rb b/test/site_test.rb index c042954..2a0cb5a 100644 --- a/test/site_test.rb +++ b/test/site_test.rb @@ -28,6 +28,42 @@ class Pressa::SiteTest < Minitest::Test assert_nil(site.image_url_for("/avatar.png")) 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 Dir.mktmpdir do |dir| 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) 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 diff --git a/test/utils/gemtext_renderer_test.rb b/test/utils/gemtext_renderer_test.rb new file mode 100644 index 0000000..0831df2 --- /dev/null +++ b/test/utils/gemtext_renderer_test.rb @@ -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 → b … and down ↓" + rendered = Pressa::Utils::GemtextRenderer.render(markdown) + + assert_includes(rendered, "a \u2192 b ... and down \u2193") + refute_includes(rendered, "→") + refute_includes(rendered, "↓") + end + + def test_render_collapses_link_only_text_lines_to_links + markdown = <<~MARKDOWN + ↓ Download volume.rb + MARKDOWN + + rendered = Pressa::Utils::GemtextRenderer.render(markdown) + + assert_equal("=> /f/volume.rb", rendered) + end +end diff --git a/test/views/layout_test.rb b/test/views/layout_test.rb index 26cb724..c186f44 100644 --- a/test/views/layout_test.rb +++ b/test/views/layout_test.rb @@ -32,6 +32,17 @@ class Pressa::Views::LayoutTest < Minitest::Test ) 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 html = Pressa::Views::Layout.new( site:, @@ -96,4 +107,37 @@ class Pressa::Views::LayoutTest < Minitest::Test assert_includes(html, "") refute_includes(html, "