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..80ad86b 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,74 @@ 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 + ) + + 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 + ) + 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 +327,93 @@ 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:) + entries = array_or_empty(value, context) + entries.map.with_index do |entry, index| + unless entry.is_a?(Hash) + raise ValidationError, "Expected #{context}[#{index}] to be a table" + end + + allowed_keys = allow_icon ? %w[label href icon] : %w[label href] + validate_allowed_keys!( + entry, + allowed_keys:, + context: "#{context}[#{index}]" + ) + + label = entry["label"] + href = entry["href"] + unless label.is_a?(String) && !label.strip.empty? + raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String" + end + 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") + + 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..bb0e9ad --- /dev/null +++ b/lib/pressa/posts/gemini_writer.rb @@ -0,0 +1,159 @@ +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| + rows << "=> #{link.href}" + 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 << "=> /posts/ Archive" + 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_archive(target_path:) + rows = ["# Archive", ""] + + @posts_by_year.sorted_years.each do |year| + rows << "=> /posts/#{year}/ #{year}" + end + + rows << "" + rows << "=> / Home" + rows << "=> #{web_url_for("/posts/")} Read on the web" + rows << "" + + file_path = File.join(target_path, "posts", "index.gmi") + Utils::FileWriter.write(path: file_path, content: rows.join("\n")) + end + + def write_year_indexes(target_path:) + @posts_by_year.sorted_years.each do |year| + year_posts = @posts_by_year.by_year[year] + write_year_index(year:, year_posts:, target_path:) + end + end + + def write_month_rollups(target_path:) + @posts_by_year.by_year.each do |year, year_posts| + year_posts.sorted_months.each do |month_posts| + write_month_rollup(year:, month_posts:, target_path:) + end + end + 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 archive" + 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 write_year_index(year:, year_posts:, target_path:) + rows = ["# #{year}", ""] + + year_posts.sorted_months.each do |month_posts| + month = month_posts.month + rows << "## #{month.name}" + month_posts.sorted_posts.each do |post| + rows.concat(post_archive_lines(post)) + end + rows << "" + end + + rows << "=> /posts/ Back to archive" + rows << "=> #{web_url_for("/posts/#{year}/")} Read on the web" + rows << "" + + file_path = File.join(target_path, "posts", year.to_s, "index.gmi") + Utils::FileWriter.write(path: file_path, content: rows.join("\n")) + end + + def write_month_rollup(year:, month_posts:, target_path:) + month = month_posts.month + rows = ["# #{month.name} #{year}", ""] + + month_posts.sorted_posts.each do |post| + rows.concat(post_archive_lines(post)) + end + + rows << "" + rows << "=> /posts/#{year}/ Back to year" + rows << "=> /posts/ Back to archive" + rows << "=> #{web_url_for("/posts/#{year}/#{month.padded}/")} Read on the web" + rows << "" + + file_path = File.join(target_path, "posts", year.to_s, month.padded, "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_archive_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..cdf1335 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,26 @@ 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_archive(target_path:) + writer.write_year_indexes(target_path:) + writer.write_month_rollups(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..a8022eb 100644 --- a/lib/pressa/site.rb +++ b/lib/pressa/site.rb @@ -5,6 +5,12 @@ module Pressa include Dry.Types() end + class OutputLink < Dry::Struct + attribute :label, Types::String + 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 +20,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 +59,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..03f099c --- /dev/null +++ b/lib/pressa/utils/gemtext_renderer.rb @@ -0,0 +1,234 @@ +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) + 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+/, " ") + CGI.unescapeHTML(cleaned).strip + 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..1e36ba9 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")) @@ -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)) + html_remote_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 @@ -203,6 +203,41 @@ module Pressa "#{start_year} - #{current_year}" end + + def html_remote_links + site.html_output_options&.remote_links || [] + end + + def remote_link_href(href) + return href if href.start_with?("http://", "https://") + + 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) + 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/site.toml b/site.toml index 6c906e3..ce3b44e 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,32 @@ 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"} +] + +[outputs.gemini] +recent_posts_limit = 20 +home_links = [ + {"label": "About", "href": "/about/"}, + {"label": "Projects", "href": "/projects/"}, + {"label": "Mastodon", "href": "https://techhub.social/@sjs"}, + {"label": "GitHub", "href": "https://github.com/samsonjs"}, + {"label": "RSS", "href": "/feed.xml"}, + {"label": "Email", "href": "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..7fb50df 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", "GitHub"], site.gemini_output_options&.home_links&.map(&:label)) + 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") @@ -375,6 +579,20 @@ 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 = [ + {"label": "About", "href": "/about/"}, + {"label": "GitHub", "href": "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..0720c0f --- /dev/null +++ b/test/posts/gemini_plugin_test.rb @@ -0,0 +1,103 @@ +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/2025/index.gmi"))) + assert(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) + year_index = File.read(File.join(target_path, "posts/2025/index.gmi")) + month_index = File.read(File.join(target_path, "posts/2025/11/index.gmi")) + + 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(year_index, "## November") + refute_includes(year_index, "=> /posts/2025/11/ November 2025") + assert_match(%r{=> /posts/2025/11/link-post/ 2025-11-07 - Link Post\n=> https://example.net/story}, year_index) + assert_includes(year_index, "=> /posts/2025/11/html-heavy/ 2025-11-06 - HTML Heavy") + assert_includes(year_index, "=> /posts/2025/11/markdown-only/ 2025-11-05 - Markdown Only") + assert_match(%r{=> /posts/2025/11/link-post/ 2025-11-07 - Link Post\n=> https://example.net/story}, month_index) + 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..86fd354 --- /dev/null +++ b/test/utils/gemtext_renderer_test.rb @@ -0,0 +1,53 @@ +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 +end diff --git a/test/views/layout_test.rb b/test/views/layout_test.rb index 26cb724..6f41547 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,34 @@ class Pressa::Views::LayoutTest < Minitest::Test assert_includes(html, "") refute_includes(html, "