mirror of
https://github.com/samsonjs/samhuri.net.git
synced 2026-03-25 09:05:47 +00:00
Publish a Gemini site and link to it from the website (#36)
* Publish on gemini in addition to the web * Publish gemini feeds, add link from web, tweak things
This commit is contained in:
parent
48ca00ed21
commit
9a0b182879
25 changed files with 1663 additions and 62 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
www
|
||||
gemini
|
||||
Tests/*/actual
|
||||
|
|
|
|||
31
AGENTS.md
31
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.
|
||||
|
|
|
|||
10
Readme.md
10
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`
|
||||
|
|
|
|||
44
bake.rb
44
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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
|
||||
|
|
|
|||
111
lib/pressa/posts/gemini_writer.rb
Normal file
111
lib/pressa/posts/gemini_writer.rb
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
require "pressa/utils/file_writer"
|
||||
require "pressa/utils/gemtext_renderer"
|
||||
|
||||
module Pressa
|
||||
module Posts
|
||||
class GeminiWriter
|
||||
RECENT_POSTS_LIMIT = 20
|
||||
|
||||
def initialize(site:, posts_by_year:)
|
||||
@site = site
|
||||
@posts_by_year = posts_by_year
|
||||
end
|
||||
|
||||
def write_posts(target_path:)
|
||||
@posts_by_year.all_posts.each do |post|
|
||||
write_post(post:, target_path:)
|
||||
end
|
||||
end
|
||||
|
||||
def write_recent_posts(target_path:, limit: RECENT_POSTS_LIMIT)
|
||||
rows = ["# #{@site.title}", ""]
|
||||
home_links.each do |link|
|
||||
label = link.label&.strip
|
||||
rows << if label.nil? || label.empty?
|
||||
"=> #{link.href}"
|
||||
else
|
||||
"=> #{link.href} #{label}"
|
||||
end
|
||||
end
|
||||
rows << "" unless home_links.empty?
|
||||
rows << "## Recent posts"
|
||||
rows << ""
|
||||
|
||||
@posts_by_year.recent_posts(limit).each do |post|
|
||||
rows << post_link_line(post)
|
||||
end
|
||||
|
||||
rows << ""
|
||||
rows << "=> #{web_url_for("/")} Website"
|
||||
rows << ""
|
||||
|
||||
file_path = File.join(target_path, "index.gmi")
|
||||
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
|
||||
end
|
||||
|
||||
def write_posts_index(target_path:)
|
||||
rows = ["# #{@site.title} posts", "## Feed", ""]
|
||||
|
||||
@posts_by_year.all_posts.each do |post|
|
||||
rows.concat(post_listing_lines(post))
|
||||
end
|
||||
|
||||
rows << ""
|
||||
rows << "=> / Home"
|
||||
rows << "=> #{web_url_for("/posts/")} Read on the web"
|
||||
rows << ""
|
||||
|
||||
content = rows.join("\n")
|
||||
Utils::FileWriter.write(path: File.join(target_path, "posts", "index.gmi"), content:)
|
||||
Utils::FileWriter.write(path: File.join(target_path, "posts", "feed.gmi"), content:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def write_post(post:, target_path:)
|
||||
rows = ["# #{post.title}", "", "#{post.formatted_date} by #{post.author}", ""]
|
||||
|
||||
if post.link_post?
|
||||
rows << "=> #{post.link}"
|
||||
rows << ""
|
||||
end
|
||||
|
||||
gemtext_body = Utils::GemtextRenderer.render(post.markdown_body)
|
||||
rows << gemtext_body unless gemtext_body.empty?
|
||||
rows << "" unless rows.last.to_s.empty?
|
||||
|
||||
rows << "=> /posts Back to posts"
|
||||
rows << "=> #{web_url_for("#{post.path}/")} Read on the web" if include_web_link?(post)
|
||||
rows << ""
|
||||
|
||||
file_path = File.join(target_path, post.path.sub(%r{^/}, ""), "index.gmi")
|
||||
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
|
||||
end
|
||||
|
||||
def post_link_line(post)
|
||||
"=> #{post.path}/ #{post.date.strftime("%Y-%m-%d")} - #{post.title}"
|
||||
end
|
||||
|
||||
def post_listing_lines(post)
|
||||
rows = [post_link_line(post)]
|
||||
rows << "=> #{post.link}" if post.link_post?
|
||||
rows
|
||||
end
|
||||
|
||||
def include_web_link?(post)
|
||||
markdown_without_fences = post.markdown_body.gsub(/```.*?```/m, "")
|
||||
markdown_without_fences.match?(
|
||||
%r{<\s*(?:a|p|div|span|ul|ol|li|audio|video|source|img|h[1-6]|blockquote|pre|code|table|tr|td|th|em|strong|br)\b}i
|
||||
)
|
||||
end
|
||||
|
||||
def web_url_for(path)
|
||||
@site.url_for(path)
|
||||
end
|
||||
|
||||
def home_links
|
||||
@site.gemini_output_options&.home_links || []
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ module Pressa
|
|||
link: metadata.link,
|
||||
tags: metadata.tags,
|
||||
body: html_body,
|
||||
markdown_body: body_markdown,
|
||||
excerpt:,
|
||||
path:
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
67
lib/pressa/utils/gemini_markdown_renderer.rb
Normal file
67
lib/pressa/utils/gemini_markdown_renderer.rb
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
require "yaml"
|
||||
require "pressa/utils/file_writer"
|
||||
require "pressa/utils/gemtext_renderer"
|
||||
|
||||
module Pressa
|
||||
module Utils
|
||||
class GeminiMarkdownRenderer
|
||||
def can_render_file?(filename:, extension:)
|
||||
extension == "md"
|
||||
end
|
||||
|
||||
def render(site:, file_path:, target_dir:)
|
||||
content = File.read(file_path)
|
||||
metadata, body_markdown = parse_content(content)
|
||||
|
||||
page_title = presence(metadata["Title"]) || File.basename(file_path, ".md").capitalize
|
||||
show_extension = ["true", "yes", true].include?(metadata["Show extension"])
|
||||
slug = File.basename(file_path, ".md")
|
||||
|
||||
relative_dir = File.dirname(file_path).sub(/^.*?\/public\/?/, "")
|
||||
relative_dir = "" if relative_dir == "."
|
||||
|
||||
canonical_html_path = if show_extension
|
||||
"/#{relative_dir}/#{slug}.html".squeeze("/")
|
||||
else
|
||||
"/#{relative_dir}/#{slug}/".squeeze("/")
|
||||
end
|
||||
|
||||
rows = ["# #{page_title}", ""]
|
||||
gemtext_body = GemtextRenderer.render(body_markdown)
|
||||
rows << gemtext_body unless gemtext_body.empty?
|
||||
rows << "" unless rows.last.to_s.empty?
|
||||
rows << "=> #{site.url_for(canonical_html_path)} Read on the web"
|
||||
rows << ""
|
||||
|
||||
output_filename = if show_extension
|
||||
"#{slug}.gmi"
|
||||
else
|
||||
File.join(slug, "index.gmi")
|
||||
end
|
||||
|
||||
output_path = File.join(target_dir, output_filename)
|
||||
FileWriter.write(path: output_path, content: rows.join("\n"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_content(content)
|
||||
if content =~ /\A---\s*\n(.*?)\n---\s*\n(.*)/m
|
||||
yaml_content = Regexp.last_match(1)
|
||||
markdown = Regexp.last_match(2)
|
||||
metadata = YAML.safe_load(yaml_content) || {}
|
||||
[metadata, markdown]
|
||||
else
|
||||
[{}, content]
|
||||
end
|
||||
end
|
||||
|
||||
def presence(value)
|
||||
return value unless value.respond_to?(:strip)
|
||||
|
||||
stripped = value.strip
|
||||
stripped.empty? ? nil : stripped
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
257
lib/pressa/utils/gemtext_renderer.rb
Normal file
257
lib/pressa/utils/gemtext_renderer.rb
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
require "cgi"
|
||||
|
||||
module Pressa
|
||||
module Utils
|
||||
class GemtextRenderer
|
||||
class << self
|
||||
def render(markdown)
|
||||
lines = markdown.to_s.gsub("\r\n", "\n").split("\n")
|
||||
link_reference_definitions = extract_link_reference_definitions(lines)
|
||||
output_lines = []
|
||||
in_preformatted_block = false
|
||||
|
||||
lines.each do |line|
|
||||
if line.start_with?("```")
|
||||
output_lines << "```"
|
||||
in_preformatted_block = !in_preformatted_block
|
||||
next
|
||||
end
|
||||
|
||||
if in_preformatted_block
|
||||
output_lines << line
|
||||
next
|
||||
end
|
||||
|
||||
next if link_reference_definition?(line)
|
||||
|
||||
converted_lines = convert_line(line, link_reference_definitions)
|
||||
output_lines.concat(converted_lines)
|
||||
end
|
||||
|
||||
squish_blank_lines(output_lines).join("\n").strip
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def convert_line(line, link_reference_definitions)
|
||||
stripped = line.strip
|
||||
return [""] if stripped.empty?
|
||||
|
||||
return convert_heading(stripped, link_reference_definitions) if heading_line?(stripped)
|
||||
return convert_list_item(stripped, link_reference_definitions) if list_item_line?(stripped)
|
||||
return convert_quote_line(stripped, link_reference_definitions) if quote_line?(stripped)
|
||||
|
||||
convert_text_line(line, link_reference_definitions)
|
||||
end
|
||||
|
||||
def convert_heading(line, link_reference_definitions)
|
||||
marker, text = line.split(/\s+/, 2)
|
||||
heading_text, links = extract_links(text.to_s, link_reference_definitions)
|
||||
rows = []
|
||||
rows << "#{marker} #{clean_inline_text(heading_text)}".strip
|
||||
rows.concat(render_link_rows(links))
|
||||
rows
|
||||
end
|
||||
|
||||
def convert_list_item(line, link_reference_definitions)
|
||||
text = line.sub(/\A[-*+]\s+/, "")
|
||||
if link_only_list_item?(text, link_reference_definitions)
|
||||
_clean_text, links = extract_links(text, link_reference_definitions)
|
||||
return render_link_rows(links)
|
||||
end
|
||||
|
||||
clean_text, links = extract_links(text, link_reference_definitions)
|
||||
rows = []
|
||||
rows << "* #{clean_inline_text(clean_text)}".strip
|
||||
rows.concat(render_link_rows(links))
|
||||
rows
|
||||
end
|
||||
|
||||
def convert_quote_line(line, link_reference_definitions)
|
||||
text = line.sub(/\A>\s?/, "")
|
||||
clean_text, links = extract_links(text, link_reference_definitions)
|
||||
rows = []
|
||||
rows << "> #{clean_inline_text(clean_text)}".strip
|
||||
rows.concat(render_link_rows(links))
|
||||
rows
|
||||
end
|
||||
|
||||
def convert_text_line(line, link_reference_definitions)
|
||||
clean_text, links = extract_links(line, link_reference_definitions)
|
||||
if !links.empty? && clean_inline_text(strip_links_from_text(line)).empty?
|
||||
return render_link_rows(links)
|
||||
end
|
||||
|
||||
rows = []
|
||||
inline_text = clean_inline_text(clean_text)
|
||||
rows << inline_text unless inline_text.empty?
|
||||
rows.concat(render_link_rows(links))
|
||||
rows.empty? ? [""] : rows
|
||||
end
|
||||
|
||||
def extract_links(text, link_reference_definitions)
|
||||
links = []
|
||||
work = text.dup
|
||||
|
||||
work.gsub!(%r{<a\s+[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>}i) do
|
||||
url = Regexp.last_match(1)
|
||||
label = clean_inline_text(strip_html_tags(Regexp.last_match(2)))
|
||||
links << [url, label]
|
||||
label
|
||||
end
|
||||
|
||||
work.gsub!(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/) do
|
||||
label = clean_inline_text(Regexp.last_match(1))
|
||||
url = Regexp.last_match(2)
|
||||
links << [url, label]
|
||||
label
|
||||
end
|
||||
|
||||
work.gsub!(/\[([^\]]+)\]\[([^\]]*)\]/) do
|
||||
label_text = Regexp.last_match(1)
|
||||
reference_key = Regexp.last_match(2)
|
||||
reference_key = label_text if reference_key.strip.empty?
|
||||
url = resolve_link_reference(link_reference_definitions, reference_key)
|
||||
next Regexp.last_match(0) unless url
|
||||
|
||||
label = clean_inline_text(label_text)
|
||||
links << [url, label]
|
||||
label
|
||||
end
|
||||
|
||||
work.scan(/(?:href|src)=["']([^"']+)["']/i) do |match|
|
||||
url = match.first
|
||||
next if links.any? { |(existing_url, _)| existing_url == url }
|
||||
|
||||
links << [url, fallback_label(url)]
|
||||
end
|
||||
|
||||
[work, links]
|
||||
end
|
||||
|
||||
def resolve_link_reference(link_reference_definitions, key)
|
||||
link_reference_definitions[normalize_link_reference_key(key)]
|
||||
end
|
||||
|
||||
def link_only_list_item?(text, link_reference_definitions)
|
||||
_clean_text, links = extract_links(text, link_reference_definitions)
|
||||
return false if links.empty?
|
||||
|
||||
remaining_text = strip_links_from_text(text)
|
||||
normalized_remaining = clean_inline_text(remaining_text)
|
||||
return true if normalized_remaining.empty?
|
||||
|
||||
links_count = links.length
|
||||
links_count == 1 && normalized_remaining.match?(/\A[\w@.+\-\/ ]+:\z/)
|
||||
end
|
||||
|
||||
def extract_link_reference_definitions(lines)
|
||||
links = {}
|
||||
lines.each do |line|
|
||||
match = line.match(/\A\s{0,3}\[([^\]]+)\]:\s*(\S+)/)
|
||||
next unless match
|
||||
|
||||
key = normalize_link_reference_key(match[1])
|
||||
value = match[2]
|
||||
value = value[1..-2] if value.start_with?("<") && value.end_with?(">")
|
||||
links[key] = value
|
||||
end
|
||||
links
|
||||
end
|
||||
|
||||
def normalize_link_reference_key(key)
|
||||
key.to_s.strip.downcase.gsub(/\s+/, " ")
|
||||
end
|
||||
|
||||
def strip_links_from_text(text)
|
||||
work = text.dup
|
||||
work.gsub!(%r{<a\s+[^>]*href=["'][^"']+["'][^>]*>.*?</a>}i, "")
|
||||
work.gsub!(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/, "")
|
||||
work.gsub!(/\[([^\]]+)\]\[([^\]]*)\]/, "")
|
||||
work
|
||||
end
|
||||
|
||||
def render_link_rows(links)
|
||||
links.filter_map do |url, label|
|
||||
next nil if url.nil? || url.strip.empty?
|
||||
"=> #{url}"
|
||||
end
|
||||
end
|
||||
|
||||
def clean_inline_text(text)
|
||||
cleaned = text.to_s.dup
|
||||
cleaned = strip_html_tags(cleaned)
|
||||
cleaned.gsub!(/`([^`]+)`/, '\1')
|
||||
cleaned.gsub!(/\*\*([^*]+)\*\*/, '\1')
|
||||
cleaned.gsub!(/__([^_]+)__/, '\1')
|
||||
cleaned.gsub!(/\*([^*]+)\*/, '\1')
|
||||
cleaned.gsub!(/_([^_]+)_/, '\1')
|
||||
cleaned.gsub!(/\s+/, " ")
|
||||
cleaned = CGI.unescapeHTML(cleaned)
|
||||
cleaned = decode_named_html_entities(cleaned)
|
||||
cleaned.strip
|
||||
end
|
||||
|
||||
def decode_named_html_entities(text)
|
||||
text.gsub(/&([A-Za-z]+);/) do
|
||||
entity = Regexp.last_match(1).downcase
|
||||
|
||||
case entity
|
||||
when "darr" then "\u2193"
|
||||
when "uarr" then "\u2191"
|
||||
when "larr" then "\u2190"
|
||||
when "rarr" then "\u2192"
|
||||
when "hellip" then "..."
|
||||
when "nbsp" then " "
|
||||
else
|
||||
"&#{Regexp.last_match(1)};"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def strip_html_tags(text)
|
||||
text.gsub(/<[^>]+>/, "")
|
||||
end
|
||||
|
||||
def fallback_label(url)
|
||||
uri_path = url.split("?").first
|
||||
basename = File.basename(uri_path.to_s)
|
||||
return url if basename.nil? || basename.empty? || basename == "/"
|
||||
|
||||
basename
|
||||
end
|
||||
|
||||
def heading_line?(line)
|
||||
line.match?(/\A\#{1,3}\s+/)
|
||||
end
|
||||
|
||||
def list_item_line?(line)
|
||||
line.match?(/\A[-*+]\s+/)
|
||||
end
|
||||
|
||||
def quote_line?(line)
|
||||
line.start_with?(">")
|
||||
end
|
||||
|
||||
def link_reference_definition?(line)
|
||||
line.match?(/\A\s{0,3}\[[^\]]+\]:\s+\S/)
|
||||
end
|
||||
|
||||
def squish_blank_lines(lines)
|
||||
output = []
|
||||
previous_blank = false
|
||||
|
||||
lines.each do |line|
|
||||
blank = line.strip.empty?
|
||||
next if blank && previous_blank
|
||||
|
||||
output << line
|
||||
previous_blank = blank
|
||||
end
|
||||
|
||||
output
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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))
|
||||
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
|
||||
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))
|
||||
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 class="icon icon-gemini-protocol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path transform="translate(12 12) scale(0.84 1.04) translate(-12 -12)" d="M18,5.3C19.35,4.97 20.66,4.54 21.94,4L21.18,2.14C18.27,3.36 15.15,4 12,4C8.85,4 5.73,3.38 2.82,2.17L2.06,4C3.34,4.54 4.65,4.97 6,5.3V18.7C4.65,19.03 3.34,19.46 2.06,20L2.82,21.86C8.7,19.42 15.3,19.42 21.18,21.86L21.94,20C20.66,19.46 19.35,19.03 18,18.7V5.3M8,18.3V5.69C9.32,5.89 10.66,6 12,6C13.34,6 14.68,5.89 16,5.69V18.31C13.35,17.9 10.65,17.9 8,18.31V18.3Z"/>
|
||||
</svg>
|
||||
SVG
|
||||
end
|
||||
|
||||
icon_renderer = remote_link_icon_renderer(link.icon)
|
||||
return nil unless icon_renderer
|
||||
|
||||
Icons.public_send(icon_renderer)
|
||||
end
|
||||
|
||||
def remote_link_icon_renderer(icon)
|
||||
case icon
|
||||
when "mastodon" then :mastodon
|
||||
when "github" then :github
|
||||
when "rss" then :rss
|
||||
when "code" then :code
|
||||
end
|
||||
end
|
||||
|
||||
def mastodon_link?(link)
|
||||
link.icon == "mastodon"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
33
site.toml
33
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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
105
test/posts/gemini_plugin_test.rb
Normal file
105
test/posts/gemini_plugin_test.rb
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
require "test_helper"
|
||||
require "fileutils"
|
||||
require "tmpdir"
|
||||
|
||||
class Pressa::Posts::GeminiPluginTest < Minitest::Test
|
||||
def site
|
||||
@site ||= Pressa::Site.new(
|
||||
author: "Sami Samhuri",
|
||||
email: "sami@samhuri.net",
|
||||
title: "samhuri.net",
|
||||
description: "blog",
|
||||
url: "https://samhuri.net",
|
||||
output_format: "gemini",
|
||||
output_options: Pressa::GeminiOutputOptions.new(
|
||||
home_links: [
|
||||
Pressa::OutputLink.new(label: "About", href: "/about/"),
|
||||
Pressa::OutputLink.new(label: "Mastodon", href: "https://techhub.social/@sjs"),
|
||||
Pressa::OutputLink.new(label: "GitHub", href: "https://github.com/samsonjs"),
|
||||
Pressa::OutputLink.new(label: "Email", href: "mailto:sami@samhuri.net")
|
||||
]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def test_render_writes_gemini_indexes_and_posts
|
||||
Dir.mktmpdir do |root|
|
||||
source_path = File.join(root, "source")
|
||||
target_path = File.join(root, "target")
|
||||
posts_dir = File.join(source_path, "posts", "2025", "11")
|
||||
FileUtils.mkdir_p(posts_dir)
|
||||
|
||||
File.write(File.join(posts_dir, "markdown-only.md"), <<~MARKDOWN)
|
||||
---
|
||||
Title: Markdown Only
|
||||
Author: Sami Samhuri
|
||||
Date: 5th November, 2025
|
||||
Timestamp: 2025-11-05T10:00:00-08:00
|
||||
---
|
||||
|
||||
This post has [one link](https://example.com) and no raw HTML.
|
||||
MARKDOWN
|
||||
|
||||
File.write(File.join(posts_dir, "html-heavy.md"), <<~MARKDOWN)
|
||||
---
|
||||
Title: HTML Heavy
|
||||
Author: Sami Samhuri
|
||||
Date: 6th November, 2025
|
||||
Timestamp: 2025-11-06T10:00:00-08:00
|
||||
---
|
||||
|
||||
<p>Raw HTML with <a href="https://example.org">a link</a>.</p>
|
||||
MARKDOWN
|
||||
|
||||
File.write(File.join(posts_dir, "link-post.md"), <<~MARKDOWN)
|
||||
---
|
||||
Title: Link Post
|
||||
Author: Sami Samhuri
|
||||
Date: 7th November, 2025
|
||||
Timestamp: 2025-11-07T10:00:00-08:00
|
||||
Link: https://example.net/story
|
||||
---
|
||||
|
||||
I wrote a short blurb about this interesting link.
|
||||
MARKDOWN
|
||||
|
||||
plugin = Pressa::Posts::GeminiPlugin.new
|
||||
plugin.setup(site:, source_path:)
|
||||
plugin.render(site:, target_path:)
|
||||
|
||||
assert(File.exist?(File.join(target_path, "index.gmi")))
|
||||
assert(File.exist?(File.join(target_path, "posts/index.gmi")))
|
||||
assert(File.exist?(File.join(target_path, "posts/feed.gmi")))
|
||||
refute(File.exist?(File.join(target_path, "posts/2025/index.gmi")))
|
||||
refute(File.exist?(File.join(target_path, "posts/2025/11/index.gmi")))
|
||||
|
||||
markdown_post = File.join(target_path, "posts/2025/11/markdown-only/index.gmi")
|
||||
html_post = File.join(target_path, "posts/2025/11/html-heavy/index.gmi")
|
||||
|
||||
assert(File.exist?(markdown_post))
|
||||
assert(File.exist?(html_post))
|
||||
|
||||
index_text = File.read(File.join(target_path, "index.gmi"))
|
||||
markdown_text = File.read(markdown_post)
|
||||
html_text = File.read(html_post)
|
||||
archive_text = File.read(File.join(target_path, "posts/index.gmi"))
|
||||
feed_text = File.read(File.join(target_path, "posts/feed.gmi"))
|
||||
|
||||
assert_includes(index_text, "=> /about/")
|
||||
assert_includes(index_text, "=> https://techhub.social/@sjs")
|
||||
assert_includes(index_text, "=> https://github.com/samsonjs")
|
||||
assert_includes(index_text, "=> mailto:sami@samhuri.net")
|
||||
refute_includes(markdown_text, "Read on the web")
|
||||
assert_includes(html_text, "Read on the web")
|
||||
assert_includes(markdown_text, "=> https://example.com")
|
||||
assert_includes(html_text, "=> https://example.org")
|
||||
assert_includes(markdown_text, "=> /posts Back to posts")
|
||||
assert_includes(archive_text, "# samhuri.net posts")
|
||||
assert_includes(archive_text, "## Feed")
|
||||
assert_match(%r{=> /posts/2025/11/link-post/ 2025-11-07 - Link Post\n=> https://example.net/story}, archive_text)
|
||||
assert_includes(archive_text, "=> /posts/2025/11/html-heavy/ 2025-11-06 - HTML Heavy")
|
||||
assert_includes(archive_text, "=> /posts/2025/11/markdown-only/ 2025-11-05 - Markdown Only")
|
||||
assert_equal(archive_text, feed_text)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"), "<html>tweets</html>")
|
||||
File.write(File.join(source_path, "public", "notes.md"), "# notes")
|
||||
|
||||
plugin = PluginSpy.new
|
||||
renderer = MarkdownRendererSpy.new
|
||||
site = build_gemini_site(plugin:, renderer:, public_excludes: ["tweets/**"])
|
||||
|
||||
Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:)
|
||||
|
||||
refute(File.exist?(File.join(target_path, "tweets", "index.html")))
|
||||
assert(File.exist?(File.join(target_path, "notes.html")))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
72
test/utils/gemtext_renderer_test.rb
Normal file
72
test/utils/gemtext_renderer_test.rb
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
require "test_helper"
|
||||
|
||||
class Pressa::Utils::GemtextRendererTest < Minitest::Test
|
||||
def test_render_resolves_reference_style_links
|
||||
markdown = <<~MARKDOWN
|
||||
I'm in [Victoria, BC][vic] and on [GitHub][].
|
||||
|
||||
[vic]: https://example.com/victoria
|
||||
[github]: https://github.com/samsonjs
|
||||
MARKDOWN
|
||||
|
||||
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
|
||||
|
||||
assert_includes(rendered, "I'm in Victoria, BC and on GitHub.")
|
||||
assert_includes(rendered, "=> https://example.com/victoria")
|
||||
assert_includes(rendered, "=> https://github.com/samsonjs")
|
||||
refute_includes(rendered, "[vic]")
|
||||
refute_includes(rendered, "[GitHub][]")
|
||||
end
|
||||
|
||||
def test_render_keeps_unresolved_reference_link_text
|
||||
markdown = "Read [this][missing] please."
|
||||
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
|
||||
|
||||
assert_includes(rendered, "Read [this][missing] please.")
|
||||
refute_includes(rendered, "=> ")
|
||||
end
|
||||
|
||||
def test_render_collapses_link_only_list_items_to_links
|
||||
markdown = <<~MARKDOWN
|
||||
## Where you can find me
|
||||
|
||||
- GitHub: [samsonjs][gh]
|
||||
- [Stack Overflow][so]
|
||||
- Mastodon: [@sjs@techhub.social][mastodon]
|
||||
- Email: [sami@samhuri.net][email]
|
||||
|
||||
[gh]: https://github.com/samsonjs
|
||||
[so]: https://stackoverflow.com/users/188752/sami-samhuri
|
||||
[mastodon]: https://techhub.social/@sjs
|
||||
[email]: mailto:sami@samhuri.net
|
||||
MARKDOWN
|
||||
|
||||
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
|
||||
|
||||
assert_includes(rendered, "=> https://github.com/samsonjs")
|
||||
assert_includes(rendered, "=> https://stackoverflow.com/users/188752/sami-samhuri")
|
||||
assert_includes(rendered, "=> https://techhub.social/@sjs")
|
||||
assert_includes(rendered, "=> mailto:sami@samhuri.net")
|
||||
refute_includes(rendered, "* GitHub: samsonjs")
|
||||
refute_includes(rendered, "* Stack Overflow")
|
||||
end
|
||||
|
||||
def test_render_decodes_common_named_html_entities
|
||||
markdown = "a → 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
|
||||
<a href="/f/volume.rb">↓ Download volume.rb</a>
|
||||
MARKDOWN
|
||||
|
||||
rendered = Pressa::Utils::GemtextRenderer.render(markdown)
|
||||
|
||||
assert_equal("=> /f/volume.rb", rendered)
|
||||
end
|
||||
end
|
||||
|
|
@ -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, "<footer>© #{current_year} <a href=\"https://samhuri.net/about\">Sami Samhuri</a></footer>")
|
||||
refute_includes(html, "<footer>© #{current_year} - #{current_year} ")
|
||||
end
|
||||
|
||||
def test_remote_links_render_from_output_config
|
||||
html = Pressa::Views::Layout.new(
|
||||
site: site_with_remote_links([
|
||||
Pressa::OutputLink.new(label: "Mastodon", href: "https://techhub.social/@sjs", icon: "mastodon"),
|
||||
Pressa::OutputLink.new(label: "Gemini", href: "gemini://samhuri.net", icon: "gemini"),
|
||||
Pressa::OutputLink.new(label: "GitHub", href: "https://github.com/samsonjs", icon: "github"),
|
||||
Pressa::OutputLink.new(label: "RSS", href: "/feed.xml", icon: "rss")
|
||||
]),
|
||||
canonical_url: "https://samhuri.net/posts/",
|
||||
content: content_view
|
||||
).call
|
||||
|
||||
assert_includes(html, "href=\"https://techhub.social/@sjs\"")
|
||||
assert_includes(html, "href=\"gemini://samhuri.net\"")
|
||||
assert_includes(html, "href=\"https://github.com/samsonjs\"")
|
||||
assert_includes(html, "href=\"https://samhuri.net/feed.xml\"")
|
||||
assert_includes(html, "aria-label=\"Mastodon\"")
|
||||
assert_includes(html, "aria-label=\"Gemini\"")
|
||||
assert_includes(html, "aria-label=\"GitHub\"")
|
||||
assert_includes(html, "aria-label=\"RSS\"")
|
||||
end
|
||||
|
||||
def test_missing_remote_links_do_not_render_hardcoded_profiles
|
||||
html = Pressa::Views::Layout.new(
|
||||
site:,
|
||||
canonical_url: "https://samhuri.net/posts/",
|
||||
content: content_view
|
||||
).call
|
||||
|
||||
refute_includes(html, "techhub.social")
|
||||
refute_includes(html, "github.com/samsonjs")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue