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
|
www
|
||||||
|
gemini
|
||||||
Tests/*/actual
|
Tests/*/actual
|
||||||
|
|
|
||||||
31
AGENTS.md
31
AGENTS.md
|
|
@ -1,7 +1,7 @@
|
||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
This repository is a single Ruby static-site generator project (the legacy Swift generators were removed).
|
This repository is a Ruby static-site generator (Pressa) that outputs both HTML and Gemini formats.
|
||||||
|
|
||||||
- Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
|
- Generator code: `lib/pressa/` (entrypoint: `lib/pressa.rb`)
|
||||||
- Build/deploy/draft tasks: `bake.rb`
|
- Build/deploy/draft tasks: `bake.rb`
|
||||||
|
|
@ -10,21 +10,28 @@ This repository is a single Ruby static-site generator project (the legacy Swift
|
||||||
- Published posts: `posts/YYYY/MM/*.md`
|
- Published posts: `posts/YYYY/MM/*.md`
|
||||||
- Static and renderable public content: `public/`
|
- Static and renderable public content: `public/`
|
||||||
- Draft posts: `public/drafts/`
|
- Draft posts: `public/drafts/`
|
||||||
- Generated output: `www/` (safe to delete/regenerate)
|
- Generated HTML output: `www/` (safe to delete/regenerate)
|
||||||
|
- Generated Gemini output: `gemini/` (safe to delete/regenerate)
|
||||||
|
- Gemini protocol reference docs: `gemini-docs/`
|
||||||
|
- CI: `.github/workflows/ci.yml` (runs coverage, lint, and debug build)
|
||||||
|
|
||||||
Keep new code under the existing `Pressa` module structure (for example `lib/pressa/posts`, `lib/pressa/projects`, `lib/pressa/views`, `lib/pressa/config`, `lib/pressa/utils`) and add matching tests under `test/`.
|
Keep new code under the existing `Pressa` module structure (for example `lib/pressa/posts`, `lib/pressa/projects`, `lib/pressa/views`, `lib/pressa/config`, `lib/pressa/utils`) and add matching tests under `test/`.
|
||||||
|
|
||||||
## Setup, Build, Test, and Development Commands
|
## Setup, Build, Test, and Development Commands
|
||||||
- Use `rbenv exec` for Ruby commands in this repository (for example `rbenv exec bundle exec ...`) to ensure the project Ruby version is used.
|
- Use `rbenv exec` for Ruby commands in this repository (for example `rbenv exec bundle exec ...`) to ensure the project Ruby version is used.
|
||||||
- `bin/bootstrap`: install prerequisites and gems (uses `rbenv` when available).
|
- `bin/bootstrap`: install prerequisites and gems (uses `rbenv` when available).
|
||||||
- `rbenv exec bundle exec bake debug`: build for `http://localhost:8000` into `www/`.
|
- `rbenv exec bundle exec bake debug`: build HTML for `http://localhost:8000` into `www/`.
|
||||||
- `rbenv exec bundle exec bake serve`: serve `www/` via WEBrick on port 8000.
|
- `rbenv exec bundle exec bake serve`: serve `www/` via WEBrick on port 8000.
|
||||||
- `rbenv exec bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
|
- `rbenv exec bundle exec bake watch target=debug`: Linux-only autorebuild loop (`inotifywait` required).
|
||||||
- `rbenv exec bundle exec bake mudge|beta|release`: build with environment-specific base URLs.
|
- `rbenv exec bundle exec bake mudge|beta|release`: build HTML with environment-specific base URLs.
|
||||||
- `rbenv exec bundle exec bake publish_beta|publish`: build and rsync `www/` to remote host.
|
- `rbenv exec bundle exec bake gemini`: build Gemini capsule into `gemini/`.
|
||||||
- `rbenv exec bundle exec bake clean`: remove `www/`.
|
- `rbenv exec bundle exec bake publish_beta`: build and rsync `www/` to beta host.
|
||||||
|
- `rbenv exec bundle exec bake publish_gemini`: build and rsync `gemini/` to production host.
|
||||||
|
- `rbenv exec bundle exec bake publish`: build and rsync both HTML and Gemini to production.
|
||||||
|
- `rbenv exec bundle exec bake clean`: remove `www/` and `gemini/`.
|
||||||
- `rbenv exec bundle exec bake test`: run test suite.
|
- `rbenv exec bundle exec bake test`: run test suite.
|
||||||
- `rbenv exec bundle exec bake lint`: lint code.
|
- `rbenv exec bundle exec bake guard`: run Guard for continuous testing.
|
||||||
|
- `rbenv exec bundle exec bake lint`: lint code with StandardRB.
|
||||||
- `rbenv exec bundle exec bake lint_fix`: auto-fix lint issues.
|
- `rbenv exec bundle exec bake lint_fix`: auto-fix lint issues.
|
||||||
- `rbenv exec bundle exec bake coverage`: run tests and report `lib/` line coverage.
|
- `rbenv exec bundle exec bake coverage`: run tests and report `lib/` line coverage.
|
||||||
- `rbenv exec bundle exec bake coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed).
|
- `rbenv exec bundle exec bake coverage_regression baseline=merge-base`: compare coverage to a baseline and fail on regression (override `baseline` as needed).
|
||||||
|
|
@ -49,7 +56,7 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
|
||||||
- Follow idiomatic Ruby style and keep code `bake lint`-clean.
|
- Follow idiomatic Ruby style and keep code `bake lint`-clean.
|
||||||
- Use 2-space indentation and descriptive `snake_case` names for methods/variables, `UpperCamelCase` for classes/modules.
|
- Use 2-space indentation and descriptive `snake_case` names for methods/variables, `UpperCamelCase` for classes/modules.
|
||||||
- Prefer small, focused classes for plugins, views, renderers, and config loaders.
|
- Prefer small, focused classes for plugins, views, renderers, and config loaders.
|
||||||
- Do not hand-edit generated files in `www/`.
|
- Do not hand-edit generated files in `www/` or `gemini/`.
|
||||||
|
|
||||||
## Testing Guidelines
|
## Testing Guidelines
|
||||||
- Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`).
|
- Use Minitest under `test/` (for example `test/posts`, `test/config`, `test/views`).
|
||||||
|
|
@ -69,7 +76,9 @@ Optional keys include `Tags`, `Link`, `Scripts`, and `Styles`.
|
||||||
## Deployment & Security Notes
|
## Deployment & Security Notes
|
||||||
- Deployment is defined in `bake.rb` via rsync over SSH.
|
- Deployment is defined in `bake.rb` via rsync over SSH.
|
||||||
- Current publish host is `mudge` with:
|
- Current publish host is `mudge` with:
|
||||||
- production: `/var/www/samhuri.net/public`
|
- production HTML: `/var/www/samhuri.net/public`
|
||||||
- beta: `/var/www/beta.samhuri.net/public`
|
- beta HTML: `/var/www/beta.samhuri.net/public`
|
||||||
- Validate `www/` before publishing to avoid shipping stale assets.
|
- production Gemini: `/var/gemini/samhuri.net`
|
||||||
|
- `bake publish` deploys both HTML and Gemini to production.
|
||||||
|
- Validate `www/` and `gemini/` before publishing to avoid shipping stale assets.
|
||||||
- Never commit credentials, SSH keys, or other secrets.
|
- Never commit credentials, SSH keys, or other secrets.
|
||||||
|
|
|
||||||
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/`
|
- Tests: `test/`
|
||||||
- Config: `site.toml` and `projects.toml`
|
- Config: `site.toml` and `projects.toml`
|
||||||
- Content: `posts/` and `public/`
|
- Content: `posts/` and `public/`
|
||||||
- Output: `www/`
|
- Output: `www/` (HTML), `gemini/` (Gemini capsule)
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ bake serve # serve www/ locally
|
||||||
|
|
||||||
Site metadata and project data are configured with TOML files at the repository root:
|
Site metadata and project data are configured with TOML files at the repository root:
|
||||||
|
|
||||||
- `site.toml`: site identity, default scripts/styles, and a `plugins` list (for example `["posts", "projects"]`), plus `projects_plugin` assets when that plugin is enabled.
|
- `site.toml`: site identity, default scripts/styles, a `plugins` list (for example `["posts", "projects"]`), and output-specific settings under `outputs.*` (for example `outputs.html.remote_links` and `outputs.gemini.{exclude_public,recent_posts_limit,home_links}`), plus `projects_plugin` assets when that plugin is enabled.
|
||||||
- `projects.toml`: project listing entries using `[[projects]]`.
|
- `projects.toml`: project listing entries using `[[projects]]`.
|
||||||
|
|
||||||
`Pressa.create_site` loads both files from the provided `source_path` and supports URL overrides for `debug`, `beta`, and `release` builds.
|
`Pressa.create_site` loads both files from the provided `source_path` and supports URL overrides for `debug`, `beta`, and `release` builds.
|
||||||
|
|
@ -58,6 +58,9 @@ If this workflow seems like a good fit, here is the minimum to make it your own:
|
||||||
- Set `plugins` in `site.toml` to explicitly enable features (`"posts"`, `"projects"`). Safe default if omitted is no plugins.
|
- Set `plugins` in `site.toml` to explicitly enable features (`"posts"`, `"projects"`). Safe default if omitted is no plugins.
|
||||||
- Define your projects in `projects.toml` using `[[projects]]` entries with `name`, `title`, `description`, and `url`.
|
- Define your projects in `projects.toml` using `[[projects]]` entries with `name`, `title`, `description`, and `url`.
|
||||||
- Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`) when using the `"projects"` plugin.
|
- Configure project-page-only assets in `site.toml` under `[projects_plugin]` (`scripts` and `styles`) when using the `"projects"` plugin.
|
||||||
|
- Configure output pipelines with `site.toml` `outputs.*` tables:
|
||||||
|
- `[outputs.html]` supports `remote_links` (array of `{label, href, icon}`).
|
||||||
|
- `[outputs.gemini]` supports `exclude_public`, `recent_posts_limit`, and `home_links` (array of `{label, href}`).
|
||||||
- Add custom plugins by implementing `Pressa::Plugin` in `lib/pressa/` and registering them in `lib/pressa/config/loader.rb`.
|
- Add custom plugins by implementing `Pressa::Plugin` in `lib/pressa/` and registering them in `lib/pressa/config/loader.rb`.
|
||||||
- Adjust rendering and layout in `lib/pressa/views/` and the static content in `public/` as needed.
|
- Adjust rendering and layout in `lib/pressa/views/` and the static content in `public/` as needed.
|
||||||
|
|
||||||
|
|
@ -67,9 +70,11 @@ Other targets:
|
||||||
bake mudge
|
bake mudge
|
||||||
bake beta
|
bake beta
|
||||||
bake release
|
bake release
|
||||||
|
bake gemini
|
||||||
bake watch target=debug
|
bake watch target=debug
|
||||||
bake clean
|
bake clean
|
||||||
bake publish_beta
|
bake publish_beta
|
||||||
|
bake publish_gemini
|
||||||
bake publish
|
bake publish
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -109,3 +114,4 @@ bake lint_fix
|
||||||
- Deployment uses `rsync` to host `mudge` (configured in `bake.rb`):
|
- Deployment uses `rsync` to host `mudge` (configured in `bake.rb`):
|
||||||
- production: `/var/www/samhuri.net/public`
|
- production: `/var/www/samhuri.net/public`
|
||||||
- beta: `/var/www/beta.samhuri.net/public`
|
- beta: `/var/www/beta.samhuri.net/public`
|
||||||
|
- gemini: `/var/gemini/samhuri.net`
|
||||||
|
|
|
||||||
44
bake.rb
44
bake.rb
|
|
@ -12,28 +12,34 @@ DRAFTS_DIR = "public/drafts".freeze
|
||||||
PUBLISH_HOST = "mudge".freeze
|
PUBLISH_HOST = "mudge".freeze
|
||||||
PRODUCTION_PUBLISH_DIR = "/var/www/samhuri.net/public".freeze
|
PRODUCTION_PUBLISH_DIR = "/var/www/samhuri.net/public".freeze
|
||||||
BETA_PUBLISH_DIR = "/var/www/beta.samhuri.net/public".freeze
|
BETA_PUBLISH_DIR = "/var/www/beta.samhuri.net/public".freeze
|
||||||
|
GEMINI_PUBLISH_DIR = "/var/gemini/samhuri.net".freeze
|
||||||
WATCHABLE_DIRECTORIES = %w[public posts lib].freeze
|
WATCHABLE_DIRECTORIES = %w[public posts lib].freeze
|
||||||
LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze
|
LINT_TARGETS = %w[bake.rb Gemfile lib test].freeze
|
||||||
BUILD_TARGETS = %w[debug mudge beta release].freeze
|
BUILD_TARGETS = %w[debug mudge beta release gemini].freeze
|
||||||
|
|
||||||
# Generate the site in debug mode (localhost:8000)
|
# Generate the site in debug mode (localhost:8000)
|
||||||
def debug
|
def debug
|
||||||
build("http://localhost:8000")
|
build("http://localhost:8000", output_format: "html", target_path: "www")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate the site for the mudge development server
|
# Generate the site for the mudge development server
|
||||||
def mudge
|
def mudge
|
||||||
build("http://mudge:8000")
|
build("http://mudge:8000", output_format: "html", target_path: "www")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate the site for beta/staging
|
# Generate the site for beta/staging
|
||||||
def beta
|
def beta
|
||||||
build("https://beta.samhuri.net")
|
build("https://beta.samhuri.net", output_format: "html", target_path: "www")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Generate the site for production
|
# Generate the site for production
|
||||||
def release
|
def release
|
||||||
build("https://samhuri.net")
|
build("https://samhuri.net", output_format: "html", target_path: "www")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Generate the Gemini capsule for production
|
||||||
|
def gemini
|
||||||
|
build("https://samhuri.net", output_format: "gemini", target_path: "gemini")
|
||||||
end
|
end
|
||||||
|
|
||||||
# Start local development server
|
# Start local development server
|
||||||
|
|
@ -109,7 +115,7 @@ def publish_draft(input_path = nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Watch content directories and rebuild on every change.
|
# Watch content directories and rebuild on every change.
|
||||||
# @parameter target [String] One of debug, mudge, beta, or release.
|
# @parameter target [String] One of debug, mudge, beta, release, or gemini.
|
||||||
def watch(target: "debug")
|
def watch(target: "debug")
|
||||||
unless command_available?("inotifywait")
|
unless command_available?("inotifywait")
|
||||||
abort "inotifywait is required (install inotify-tools)."
|
abort "inotifywait is required (install inotify-tools)."
|
||||||
|
|
@ -129,16 +135,24 @@ def publish_beta
|
||||||
run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true)
|
run_rsync(local_paths: ["www/"], publish_dir: BETA_PUBLISH_DIR, dry_run: false, delete: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Publish Gemini capsule to production
|
||||||
|
def publish_gemini
|
||||||
|
gemini
|
||||||
|
run_rsync(local_paths: ["gemini/"], publish_dir: GEMINI_PUBLISH_DIR, dry_run: false, delete: true)
|
||||||
|
end
|
||||||
|
|
||||||
# Publish to production server
|
# Publish to production server
|
||||||
def publish
|
def publish
|
||||||
release
|
release
|
||||||
run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true)
|
run_rsync(local_paths: ["www/"], publish_dir: PRODUCTION_PUBLISH_DIR, dry_run: false, delete: true)
|
||||||
|
publish_gemini
|
||||||
end
|
end
|
||||||
|
|
||||||
# Clean generated files
|
# Clean generated files
|
||||||
def clean
|
def clean
|
||||||
FileUtils.rm_rf("www")
|
FileUtils.rm_rf("www")
|
||||||
puts "Cleaned www/ directory"
|
FileUtils.rm_rf("gemini")
|
||||||
|
puts "Cleaned www/ and gemini/ directories"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Default task: run coverage and lint.
|
# Default task: run coverage and lint.
|
||||||
|
|
@ -358,16 +372,18 @@ def capture_command_optional(*command, chdir: Dir.pwd)
|
||||||
""
|
""
|
||||||
end
|
end
|
||||||
|
|
||||||
# Build the site with specified URL
|
# Build the site with specified URL and output format.
|
||||||
# @parameter url [String] The site URL to use
|
# @parameter url [String] The site URL to use.
|
||||||
def build(url)
|
# @parameter output_format [String] One of html or gemini.
|
||||||
|
# @parameter target_path [String] Target directory for generated output.
|
||||||
|
def build(url, output_format:, target_path:)
|
||||||
require "pressa"
|
require "pressa"
|
||||||
|
|
||||||
puts "Building site for #{url}..."
|
puts "Building #{output_format} site for #{url}..."
|
||||||
site = Pressa.create_site(source_path: ".", url_override: url)
|
site = Pressa.create_site(source_path: ".", url_override: url, output_format:)
|
||||||
generator = Pressa::SiteGenerator.new(site:)
|
generator = Pressa::SiteGenerator.new(site:)
|
||||||
generator.generate(source_path: ".", target_path: "www")
|
generator.generate(source_path: ".", target_path:)
|
||||||
puts "Site built successfully in www/"
|
puts "Site built successfully in #{target_path}/"
|
||||||
end
|
end
|
||||||
|
|
||||||
def run_build_target(target)
|
def run_build_target(target)
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,12 @@ require "pressa/plugin"
|
||||||
require "pressa/posts/plugin"
|
require "pressa/posts/plugin"
|
||||||
require "pressa/projects/plugin"
|
require "pressa/projects/plugin"
|
||||||
require "pressa/utils/markdown_renderer"
|
require "pressa/utils/markdown_renderer"
|
||||||
|
require "pressa/utils/gemini_markdown_renderer"
|
||||||
require "pressa/config/loader"
|
require "pressa/config/loader"
|
||||||
|
|
||||||
module Pressa
|
module Pressa
|
||||||
def self.create_site(source_path: ".", url_override: nil)
|
def self.create_site(source_path: ".", url_override: nil, output_format: "html")
|
||||||
loader = Config::Loader.new(source_path:)
|
loader = Config::Loader.new(source_path:)
|
||||||
loader.build_site(url_override:)
|
loader.build_site(url_override:, output_format:)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ require "pressa/site"
|
||||||
require "pressa/posts/plugin"
|
require "pressa/posts/plugin"
|
||||||
require "pressa/projects/plugin"
|
require "pressa/projects/plugin"
|
||||||
require "pressa/utils/markdown_renderer"
|
require "pressa/utils/markdown_renderer"
|
||||||
|
require "pressa/utils/gemini_markdown_renderer"
|
||||||
require "pressa/config/simple_toml"
|
require "pressa/config/simple_toml"
|
||||||
|
|
||||||
module Pressa
|
module Pressa
|
||||||
|
|
@ -16,13 +17,16 @@ module Pressa
|
||||||
@source_path = source_path
|
@source_path = source_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_site(url_override: nil)
|
def build_site(url_override: nil, output_format: "html")
|
||||||
site_config = load_toml("site.toml")
|
site_config = load_toml("site.toml")
|
||||||
|
|
||||||
validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml")
|
validate_required!(site_config, REQUIRED_SITE_KEYS, context: "site.toml")
|
||||||
|
validate_no_legacy_output_keys!(site_config)
|
||||||
|
|
||||||
|
normalized_output_format = normalize_output_format(output_format)
|
||||||
site_url = url_override || site_config["url"]
|
site_url = url_override || site_config["url"]
|
||||||
plugins = build_plugins(site_config)
|
output_options = build_output_options(site_config:, output_format: normalized_output_format)
|
||||||
|
plugins = build_plugins(site_config, output_format: normalized_output_format)
|
||||||
|
|
||||||
Site.new(
|
Site.new(
|
||||||
author: site_config["author"],
|
author: site_config["author"],
|
||||||
|
|
@ -30,13 +34,17 @@ module Pressa
|
||||||
title: site_config["title"],
|
title: site_config["title"],
|
||||||
description: site_config["description"],
|
description: site_config["description"],
|
||||||
url: site_url,
|
url: site_url,
|
||||||
|
fediverse_creator: build_optional_string(
|
||||||
|
site_config["fediverse_creator"],
|
||||||
|
context: "site.toml fediverse_creator"
|
||||||
|
),
|
||||||
image_url: normalize_image_url(site_config["image_url"], site_url),
|
image_url: normalize_image_url(site_config["image_url"], site_url),
|
||||||
scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"),
|
scripts: build_scripts(site_config["scripts"], context: "site.toml scripts"),
|
||||||
styles: build_styles(site_config["styles"], context: "site.toml styles"),
|
styles: build_styles(site_config["styles"], context: "site.toml styles"),
|
||||||
plugins:,
|
plugins:,
|
||||||
renderers: [
|
renderers: build_renderers(output_format: normalized_output_format),
|
||||||
Utils::MarkdownRenderer.new
|
output_format: normalized_output_format,
|
||||||
]
|
output_options:
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -80,21 +88,53 @@ module Pressa
|
||||||
raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}"
|
raise ValidationError, "Missing required #{context} keys: #{missing.join(", ")}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_plugins(site_config)
|
def validate_no_legacy_output_keys!(site_config)
|
||||||
|
if site_config.key?("output")
|
||||||
|
raise ValidationError, "Legacy key 'output' is no longer supported; use 'outputs'"
|
||||||
|
end
|
||||||
|
|
||||||
|
if site_config.key?("mastodon_url") || site_config.key?("github_url")
|
||||||
|
raise ValidationError, "Legacy keys 'mastodon_url'/'github_url' are no longer supported; use outputs.html.remote_links or outputs.gemini.home_links"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_plugins(site_config, output_format:)
|
||||||
plugin_names = parse_plugin_names(site_config["plugins"])
|
plugin_names = parse_plugin_names(site_config["plugins"])
|
||||||
|
|
||||||
plugin_names.map.with_index do |plugin_name, index|
|
plugin_names.map.with_index do |plugin_name, index|
|
||||||
case plugin_name
|
case plugin_name
|
||||||
when "posts"
|
when "posts"
|
||||||
Posts::Plugin.new
|
posts_plugin_for(output_format)
|
||||||
when "projects"
|
when "projects"
|
||||||
build_projects_plugin(site_config)
|
build_projects_plugin(site_config, output_format:)
|
||||||
else
|
else
|
||||||
raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]"
|
raise ValidationError, "Unknown plugin '#{plugin_name}' at site.toml plugins[#{index}]"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_renderers(output_format:)
|
||||||
|
case output_format
|
||||||
|
when "html"
|
||||||
|
[Utils::MarkdownRenderer.new]
|
||||||
|
when "gemini"
|
||||||
|
[Utils::GeminiMarkdownRenderer.new]
|
||||||
|
else
|
||||||
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def posts_plugin_for(output_format)
|
||||||
|
case output_format
|
||||||
|
when "html"
|
||||||
|
Posts::HTMLPlugin.new
|
||||||
|
when "gemini"
|
||||||
|
Posts::GeminiPlugin.new
|
||||||
|
else
|
||||||
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def parse_plugin_names(value)
|
def parse_plugin_names(value)
|
||||||
return [] if value.nil?
|
return [] if value.nil?
|
||||||
raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array)
|
raise ValidationError, "Expected site.toml plugins to be an array" unless value.is_a?(Array)
|
||||||
|
|
@ -116,16 +156,23 @@ module Pressa
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_projects_plugin(site_config)
|
def build_projects_plugin(site_config, output_format:)
|
||||||
projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin")
|
projects_plugin = hash_or_empty(site_config["projects_plugin"], "site.toml projects_plugin")
|
||||||
projects_config = load_toml("projects.toml")
|
projects_config = load_toml("projects.toml")
|
||||||
projects = build_projects(projects_config)
|
projects = build_projects(projects_config)
|
||||||
|
|
||||||
Projects::Plugin.new(
|
case output_format
|
||||||
|
when "html"
|
||||||
|
Projects::HTMLPlugin.new(
|
||||||
projects:,
|
projects:,
|
||||||
scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"),
|
scripts: build_scripts(projects_plugin["scripts"], context: "site.toml projects_plugin.scripts"),
|
||||||
styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles")
|
styles: build_styles(projects_plugin["styles"], context: "site.toml projects_plugin.styles")
|
||||||
)
|
)
|
||||||
|
when "gemini"
|
||||||
|
Projects::GeminiPlugin.new(projects:)
|
||||||
|
else
|
||||||
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def hash_or_empty(value, context)
|
def hash_or_empty(value, context)
|
||||||
|
|
@ -135,6 +182,78 @@ module Pressa
|
||||||
raise ValidationError, "Expected #{context} to be a table"
|
raise ValidationError, "Expected #{context} to be a table"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_output_options(site_config:, output_format:)
|
||||||
|
outputs_config = hash_or_empty(site_config["outputs"], "site.toml outputs")
|
||||||
|
validate_allowed_keys!(
|
||||||
|
outputs_config,
|
||||||
|
allowed_keys: %w[html gemini],
|
||||||
|
context: "site.toml outputs"
|
||||||
|
)
|
||||||
|
format_config = hash_or_empty(outputs_config[output_format], "site.toml outputs.#{output_format}")
|
||||||
|
|
||||||
|
case output_format
|
||||||
|
when "html"
|
||||||
|
build_html_output_options(format_config:)
|
||||||
|
when "gemini"
|
||||||
|
build_gemini_output_options(format_config:)
|
||||||
|
else
|
||||||
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_html_output_options(format_config:)
|
||||||
|
validate_allowed_keys!(
|
||||||
|
format_config,
|
||||||
|
allowed_keys: %w[exclude_public remote_links],
|
||||||
|
context: "site.toml outputs.html"
|
||||||
|
)
|
||||||
|
public_excludes = build_public_excludes(
|
||||||
|
format_config["exclude_public"],
|
||||||
|
context: "site.toml outputs.html.exclude_public"
|
||||||
|
)
|
||||||
|
remote_links = build_output_links(
|
||||||
|
format_config["remote_links"],
|
||||||
|
context: "site.toml outputs.html.remote_links",
|
||||||
|
allow_icon: true,
|
||||||
|
allow_label_optional: false,
|
||||||
|
allow_string_entries: false
|
||||||
|
)
|
||||||
|
|
||||||
|
HTMLOutputOptions.new(
|
||||||
|
public_excludes:,
|
||||||
|
remote_links:
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_gemini_output_options(format_config:)
|
||||||
|
validate_allowed_keys!(
|
||||||
|
format_config,
|
||||||
|
allowed_keys: %w[exclude_public recent_posts_limit home_links],
|
||||||
|
context: "site.toml outputs.gemini"
|
||||||
|
)
|
||||||
|
public_excludes = build_public_excludes(
|
||||||
|
format_config["exclude_public"],
|
||||||
|
context: "site.toml outputs.gemini.exclude_public"
|
||||||
|
)
|
||||||
|
home_links = build_output_links(
|
||||||
|
format_config["home_links"],
|
||||||
|
context: "site.toml outputs.gemini.home_links",
|
||||||
|
allow_icon: false,
|
||||||
|
allow_label_optional: true,
|
||||||
|
allow_string_entries: true
|
||||||
|
)
|
||||||
|
recent_posts_limit = build_recent_posts_limit(
|
||||||
|
format_config["recent_posts_limit"],
|
||||||
|
context: "site.toml outputs.gemini.recent_posts_limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
GeminiOutputOptions.new(
|
||||||
|
public_excludes:,
|
||||||
|
recent_posts_limit:,
|
||||||
|
home_links:
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def build_scripts(value, context:)
|
def build_scripts(value, context:)
|
||||||
entries = array_or_empty(value, context)
|
entries = array_or_empty(value, context)
|
||||||
|
|
||||||
|
|
@ -212,6 +331,110 @@ module Pressa
|
||||||
|
|
||||||
raise ValidationError, "Expected #{context} to start with / or use http(s) scheme"
|
raise ValidationError, "Expected #{context} to start with / or use http(s) scheme"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_public_excludes(value, context:)
|
||||||
|
entries = array_or_empty(value, context)
|
||||||
|
entries.map.with_index do |entry, index|
|
||||||
|
unless entry.is_a?(String) && !entry.strip.empty?
|
||||||
|
raise ValidationError, "Expected #{context}[#{index}] to be a non-empty String"
|
||||||
|
end
|
||||||
|
|
||||||
|
entry.strip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_output_links(value, context:, allow_icon:, allow_label_optional:, allow_string_entries:)
|
||||||
|
entries = array_or_empty(value, context)
|
||||||
|
entries.map.with_index do |entry, index|
|
||||||
|
if allow_string_entries && entry.is_a?(String)
|
||||||
|
href = entry
|
||||||
|
unless !href.strip.empty?
|
||||||
|
raise ValidationError, "Expected #{context}[#{index}] to be a non-empty String"
|
||||||
|
end
|
||||||
|
validate_link_href!(href.strip, context: "#{context}[#{index}]")
|
||||||
|
|
||||||
|
next OutputLink.new(label: nil, href: href.strip, icon: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless entry.is_a?(Hash)
|
||||||
|
raise ValidationError, "Expected #{context}[#{index}] to be a String or table"
|
||||||
|
end
|
||||||
|
|
||||||
|
allowed_keys = allow_icon ? %w[label href icon] : %w[label href]
|
||||||
|
validate_allowed_keys!(
|
||||||
|
entry,
|
||||||
|
allowed_keys:,
|
||||||
|
context: "#{context}[#{index}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
href = entry["href"]
|
||||||
|
unless href.is_a?(String) && !href.strip.empty?
|
||||||
|
raise ValidationError, "Expected #{context}[#{index}].href to be a non-empty String"
|
||||||
|
end
|
||||||
|
validate_link_href!(href.strip, context: "#{context}[#{index}].href")
|
||||||
|
|
||||||
|
label = entry["label"]
|
||||||
|
if label.nil?
|
||||||
|
unless allow_label_optional
|
||||||
|
raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
unless label.is_a?(String) && !label.strip.empty?
|
||||||
|
raise ValidationError, "Expected #{context}[#{index}].label to be a non-empty String"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
icon = entry["icon"]
|
||||||
|
unless allow_icon
|
||||||
|
if entry.key?("icon")
|
||||||
|
raise ValidationError, "Unexpected #{context}[#{index}].icon; icons are only supported for outputs.html.remote_links"
|
||||||
|
end
|
||||||
|
|
||||||
|
icon = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
if allow_icon && !icon.nil? && (!icon.is_a?(String) || icon.strip.empty?)
|
||||||
|
raise ValidationError, "Expected #{context}[#{index}].icon to be a non-empty String"
|
||||||
|
end
|
||||||
|
|
||||||
|
OutputLink.new(label: label&.strip, href: href.strip, icon: icon&.strip)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_link_href!(value, context:)
|
||||||
|
return if value.start_with?("/")
|
||||||
|
return if value.match?(/\A[a-z][a-z0-9+\-.]*:/i)
|
||||||
|
|
||||||
|
raise ValidationError, "Expected #{context} to start with / or include a URI scheme"
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_recent_posts_limit(value, context:)
|
||||||
|
return 20 if value.nil?
|
||||||
|
return value if value.is_a?(Integer) && value.positive?
|
||||||
|
|
||||||
|
raise ValidationError, "Expected #{context} to be a positive Integer"
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_output_format(output_format)
|
||||||
|
value = output_format.to_s.strip.downcase
|
||||||
|
return value if %w[html gemini].include?(value)
|
||||||
|
|
||||||
|
raise ValidationError, "Unsupported output format '#{output_format}'"
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_optional_string(value, context:)
|
||||||
|
return nil if value.nil?
|
||||||
|
return value if value.is_a?(String) && !value.strip.empty?
|
||||||
|
|
||||||
|
raise ValidationError, "Expected #{context} to be a non-empty String"
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_allowed_keys!(hash, allowed_keys:, context:)
|
||||||
|
unknown = hash.keys - allowed_keys
|
||||||
|
return if unknown.empty?
|
||||||
|
|
||||||
|
raise ValidationError, "Unknown key(s) in #{context}: #{unknown.join(", ")}"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
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 :link, Types::String.optional.default(nil)
|
||||||
attribute :tags, Types::Array.of(Types::String).default([].freeze)
|
attribute :tags, Types::Array.of(Types::String).default([].freeze)
|
||||||
attribute :body, Types::String
|
attribute :body, Types::String
|
||||||
|
attribute :markdown_body, Types::String.default("".freeze)
|
||||||
attribute :excerpt, Types::String
|
attribute :excerpt, Types::String
|
||||||
attribute :path, Types::String
|
attribute :path, Types::String
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
require "pressa/plugin"
|
require "pressa/plugin"
|
||||||
require "pressa/posts/repo"
|
require "pressa/posts/repo"
|
||||||
require "pressa/posts/writer"
|
require "pressa/posts/writer"
|
||||||
|
require "pressa/posts/gemini_writer"
|
||||||
require "pressa/posts/json_feed"
|
require "pressa/posts/json_feed"
|
||||||
require "pressa/posts/rss_feed"
|
require "pressa/posts/rss_feed"
|
||||||
|
|
||||||
module Pressa
|
module Pressa
|
||||||
module Posts
|
module Posts
|
||||||
class Plugin < Pressa::Plugin
|
class BasePlugin < Pressa::Plugin
|
||||||
attr_reader :posts_by_year
|
attr_reader :posts_by_year
|
||||||
|
|
||||||
def setup(site:, source_path:)
|
def setup(site:, source_path:)
|
||||||
|
|
@ -16,7 +17,9 @@ module Pressa
|
||||||
repo = PostRepo.new
|
repo = PostRepo.new
|
||||||
@posts_by_year = repo.read_posts(posts_dir)
|
@posts_by_year = repo.read_posts(posts_dir)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class HTMLPlugin < BasePlugin
|
||||||
def render(site:, target_path:)
|
def render(site:, target_path:)
|
||||||
return unless @posts_by_year
|
return unless @posts_by_year
|
||||||
|
|
||||||
|
|
@ -34,5 +37,24 @@ module Pressa
|
||||||
rss_feed.write_feed(target_path:, limit: 30)
|
rss_feed.write_feed(target_path:, limit: 30)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class GeminiPlugin < BasePlugin
|
||||||
|
def render(site:, target_path:)
|
||||||
|
return unless @posts_by_year
|
||||||
|
|
||||||
|
writer = GeminiWriter.new(site:, posts_by_year: @posts_by_year)
|
||||||
|
writer.write_posts(target_path:)
|
||||||
|
writer.write_recent_posts(target_path:, limit: gemini_recent_posts_limit(site))
|
||||||
|
writer.write_posts_index(target_path:)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def gemini_recent_posts_limit(site)
|
||||||
|
site.gemini_output_options&.recent_posts_limit || GeminiWriter::RECENT_POSTS_LIMIT
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Plugin = HTMLPlugin
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ module Pressa
|
||||||
link: metadata.link,
|
link: metadata.link,
|
||||||
tags: metadata.tags,
|
tags: metadata.tags,
|
||||||
body: html_body,
|
body: html_body,
|
||||||
|
markdown_body: body_markdown,
|
||||||
excerpt:,
|
excerpt:,
|
||||||
path:
|
path:
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ require "pressa/projects/models"
|
||||||
|
|
||||||
module Pressa
|
module Pressa
|
||||||
module Projects
|
module Projects
|
||||||
class Plugin < Pressa::Plugin
|
class HTMLPlugin < Pressa::Plugin
|
||||||
attr_reader :scripts, :styles
|
attr_reader :scripts, :styles
|
||||||
|
|
||||||
def initialize(projects: [], scripts: [], styles: [])
|
def initialize(projects: [], scripts: [], styles: [])
|
||||||
|
|
@ -82,5 +82,57 @@ module Pressa
|
||||||
layout.call
|
layout.call
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class GeminiPlugin < Pressa::Plugin
|
||||||
|
def initialize(projects: [])
|
||||||
|
@projects = projects
|
||||||
|
end
|
||||||
|
|
||||||
|
def setup(site:, source_path:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render(site:, target_path:)
|
||||||
|
write_projects_index(site:, target_path:)
|
||||||
|
|
||||||
|
@projects.each do |project|
|
||||||
|
write_project_page(project:, site:, target_path:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def write_projects_index(site:, target_path:)
|
||||||
|
rows = ["# Projects", ""]
|
||||||
|
@projects.each do |project|
|
||||||
|
rows << "## #{project.title}"
|
||||||
|
rows << project.description
|
||||||
|
rows << "=> #{project.url}"
|
||||||
|
rows << ""
|
||||||
|
end
|
||||||
|
rows << "=> / Home"
|
||||||
|
rows << "=> #{site.url_for("/projects/")} Read on the web"
|
||||||
|
rows << ""
|
||||||
|
|
||||||
|
file_path = File.join(target_path, "projects", "index.gmi")
|
||||||
|
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
|
||||||
|
end
|
||||||
|
|
||||||
|
def write_project_page(project:, site:, target_path:)
|
||||||
|
rows = [
|
||||||
|
"# #{project.title}",
|
||||||
|
"",
|
||||||
|
project.description,
|
||||||
|
"",
|
||||||
|
"=> #{project.url}",
|
||||||
|
"=> /projects/ Back to projects",
|
||||||
|
""
|
||||||
|
]
|
||||||
|
|
||||||
|
file_path = File.join(target_path, "projects", project.name, "index.gmi")
|
||||||
|
Utils::FileWriter.write(path: file_path, content: rows.join("\n"))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Plugin = HTMLPlugin
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ module Pressa
|
||||||
include Dry.Types()
|
include Dry.Types()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class OutputLink < Dry::Struct
|
||||||
|
# label is required for HTML remote links, but Gemini home_links may omit it.
|
||||||
|
attribute :label, Types::String.optional.default(nil)
|
||||||
|
attribute :href, Types::String
|
||||||
|
attribute :icon, Types::String.optional.default(nil)
|
||||||
|
end
|
||||||
|
|
||||||
class Script < Dry::Struct
|
class Script < Dry::Struct
|
||||||
attribute :src, Types::String
|
attribute :src, Types::String
|
||||||
attribute :defer, Types::Bool.default(true)
|
attribute :defer, Types::Bool.default(true)
|
||||||
|
|
@ -14,18 +21,36 @@ module Pressa
|
||||||
attribute :href, Types::String
|
attribute :href, Types::String
|
||||||
end
|
end
|
||||||
|
|
||||||
|
class OutputOptions < Dry::Struct
|
||||||
|
attribute :public_excludes, Types::Array.of(Types::String).default([].freeze)
|
||||||
|
end
|
||||||
|
|
||||||
|
class HTMLOutputOptions < OutputOptions
|
||||||
|
attribute :remote_links, Types::Array.of(OutputLink).default([].freeze)
|
||||||
|
end
|
||||||
|
|
||||||
|
class GeminiOutputOptions < OutputOptions
|
||||||
|
attribute :recent_posts_limit, Types::Integer.default(20)
|
||||||
|
attribute :home_links, Types::Array.of(OutputLink).default([].freeze)
|
||||||
|
end
|
||||||
|
|
||||||
class Site < Dry::Struct
|
class Site < Dry::Struct
|
||||||
|
OUTPUT_OPTIONS = Types.Instance(OutputOptions)
|
||||||
|
|
||||||
attribute :author, Types::String
|
attribute :author, Types::String
|
||||||
attribute :email, Types::String
|
attribute :email, Types::String
|
||||||
attribute :title, Types::String
|
attribute :title, Types::String
|
||||||
attribute :description, Types::String
|
attribute :description, Types::String
|
||||||
attribute :url, Types::String
|
attribute :url, Types::String
|
||||||
|
attribute :fediverse_creator, Types::String.optional.default(nil)
|
||||||
attribute :image_url, Types::String.optional.default(nil)
|
attribute :image_url, Types::String.optional.default(nil)
|
||||||
attribute :copyright_start_year, Types::Integer.optional.default(nil)
|
attribute :copyright_start_year, Types::Integer.optional.default(nil)
|
||||||
attribute :scripts, Types::Array.of(Script).default([].freeze)
|
attribute :scripts, Types::Array.of(Script).default([].freeze)
|
||||||
attribute :styles, Types::Array.of(Stylesheet).default([].freeze)
|
attribute :styles, Types::Array.of(Stylesheet).default([].freeze)
|
||||||
attribute :plugins, Types::Array.default([].freeze)
|
attribute :plugins, Types::Array.default([].freeze)
|
||||||
attribute :renderers, Types::Array.default([].freeze)
|
attribute :renderers, Types::Array.default([].freeze)
|
||||||
|
attribute :output_format, Types::String.default("html".freeze).enum("html", "gemini")
|
||||||
|
attribute :output_options, OUTPUT_OPTIONS.default { HTMLOutputOptions.new }
|
||||||
|
|
||||||
def url_for(path)
|
def url_for(path)
|
||||||
"#{url}#{path}"
|
"#{url}#{path}"
|
||||||
|
|
@ -35,5 +60,17 @@ module Pressa
|
||||||
return nil unless image_url
|
return nil unless image_url
|
||||||
"#{image_url}#{path}"
|
"#{image_url}#{path}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def public_excludes
|
||||||
|
output_options.public_excludes
|
||||||
|
end
|
||||||
|
|
||||||
|
def html_output_options
|
||||||
|
output_options if output_options.is_a?(HTMLOutputOptions)
|
||||||
|
end
|
||||||
|
|
||||||
|
def gemini_output_options
|
||||||
|
output_options if output_options.is_a?(GeminiOutputOptions)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ module Pressa
|
||||||
Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
|
Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
|
||||||
next if File.directory?(source_file)
|
next if File.directory?(source_file)
|
||||||
next if skip_file?(source_file)
|
next if skip_file?(source_file)
|
||||||
|
next if skip_for_output_format?(source_file:, public_dir:)
|
||||||
|
|
||||||
filename = File.basename(source_file)
|
filename = File.basename(source_file)
|
||||||
ext = File.extname(source_file)[1..]
|
ext = File.extname(source_file)[1..]
|
||||||
|
|
@ -78,6 +79,7 @@ module Pressa
|
||||||
Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
|
Dir.glob(File.join(public_dir, "**", "*"), File::FNM_DOTMATCH).each do |source_file|
|
||||||
next if File.directory?(source_file)
|
next if File.directory?(source_file)
|
||||||
next if skip_file?(source_file)
|
next if skip_file?(source_file)
|
||||||
|
next if skip_for_output_format?(source_file:, public_dir:)
|
||||||
|
|
||||||
filename = File.basename(source_file)
|
filename = File.basename(source_file)
|
||||||
ext = File.extname(source_file)[1..]
|
ext = File.extname(source_file)[1..]
|
||||||
|
|
@ -102,9 +104,31 @@ module Pressa
|
||||||
basename.start_with?(".")
|
basename.start_with?(".")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def skip_for_output_format?(source_file:, public_dir:)
|
||||||
|
relative_path = source_file.sub("#{public_dir}/", "")
|
||||||
|
site.public_excludes.any? do |pattern|
|
||||||
|
excluded_by_pattern?(relative_path:, pattern:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def excluded_by_pattern?(relative_path:, pattern:)
|
||||||
|
normalized = pattern.sub(%r{\A/+}, "")
|
||||||
|
|
||||||
|
if normalized.end_with?("/**")
|
||||||
|
prefix = normalized.delete_suffix("/**")
|
||||||
|
return relative_path.start_with?("#{prefix}/") || relative_path == prefix
|
||||||
|
end
|
||||||
|
|
||||||
|
File.fnmatch?(normalized, relative_path, File::FNM_PATHNAME)
|
||||||
|
end
|
||||||
|
|
||||||
def site_with_copyright_start_year(base_site)
|
def site_with_copyright_start_year(base_site)
|
||||||
start_year = find_copyright_start_year(base_site)
|
start_year = find_copyright_start_year(base_site)
|
||||||
Site.new(**base_site.to_h.merge(copyright_start_year: start_year))
|
attrs = base_site.to_h.merge(
|
||||||
|
output_options: base_site.output_options,
|
||||||
|
copyright_start_year: start_year
|
||||||
|
)
|
||||||
|
Site.new(**attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_copyright_start_year(base_site)
|
def find_copyright_start_year(base_site)
|
||||||
|
|
|
||||||
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
|
title: site.title
|
||||||
)
|
)
|
||||||
|
|
||||||
meta(name: "fediverse:creator", content: "@sjs@techhub.social")
|
meta(name: "fediverse:creator", content: site.fediverse_creator) if site.fediverse_creator
|
||||||
link(rel: "author", type: "text/plain", href: site.url_for("/humans.txt"))
|
link(rel: "author", type: "text/plain", href: site.url_for("/humans.txt"))
|
||||||
link(rel: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png"))
|
link(rel: "icon", type: "image/png", href: site.url_for("/images/favicon-32x32.png"))
|
||||||
link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon"))
|
link(rel: "shortcut icon", href: site.url_for("/images/favicon.icon"))
|
||||||
|
|
@ -125,7 +125,7 @@ module Pressa
|
||||||
h1 do
|
h1 do
|
||||||
a(href: site.url) { site.title }
|
a(href: site.url) { site.title }
|
||||||
end
|
end
|
||||||
br
|
|
||||||
h4 do
|
h4 do
|
||||||
plain "By "
|
plain "By "
|
||||||
a(href: site.url_for("/about")) { site.author }
|
a(href: site.url_for("/about")) { site.author }
|
||||||
|
|
@ -134,19 +134,19 @@ module Pressa
|
||||||
|
|
||||||
nav(class: "remote") do
|
nav(class: "remote") do
|
||||||
ul do
|
ul do
|
||||||
li(class: "mastodon") do
|
remote_nav_links.each do |link|
|
||||||
a(rel: "me", "aria-label": "Mastodon", href: "https://techhub.social/@sjs") do
|
li(class: remote_link_class(link)) do
|
||||||
raw(safe(Icons.mastodon))
|
attrs = {"aria-label": link.label, href: remote_link_href(link.href)}
|
||||||
|
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
|
||||||
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
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -177,6 +177,96 @@ module Pressa
|
||||||
attrs[:defer] = true if scr.defer
|
attrs[:defer] = true if scr.defer
|
||||||
script(**attrs)
|
script(**attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
render_gemini_fallback_script
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_gemini_fallback_script
|
||||||
|
# Inline so the behavior ships with the base HTML layout without needing
|
||||||
|
# separate asset management for one small handler.
|
||||||
|
script do
|
||||||
|
raw(safe(<<~JS))
|
||||||
|
(function () {
|
||||||
|
function isPlainLeftClick(e) {
|
||||||
|
return (
|
||||||
|
e.button === 0 &&
|
||||||
|
!e.defaultPrevented &&
|
||||||
|
!e.metaKey &&
|
||||||
|
!e.ctrlKey &&
|
||||||
|
!e.shiftKey &&
|
||||||
|
!e.altKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGeminiFallback() {
|
||||||
|
var links = document.querySelectorAll(
|
||||||
|
'header.primary nav.remote a[href^="gemini://"]'
|
||||||
|
);
|
||||||
|
if (!links || links.length === 0) return;
|
||||||
|
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
(function (link) {
|
||||||
|
link.addEventListener("click", function (e) {
|
||||||
|
if (!isPlainLeftClick(e)) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var geminiHref = link.getAttribute("href");
|
||||||
|
var fallbackHref = "https://geminiprotocol.net";
|
||||||
|
|
||||||
|
var done = false;
|
||||||
|
var fallbackTimer = null;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (fallbackTimer) window.clearTimeout(fallbackTimer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.removeEventListener("pagehide", onPageHide);
|
||||||
|
window.removeEventListener("blur", onBlur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDone() {
|
||||||
|
done = true;
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
// If a handler opens and the browser backgrounded, consider it "successful".
|
||||||
|
if (document.visibilityState === "hidden") markDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageHide() {
|
||||||
|
markDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
// Some browsers blur the page when a protocol handler is invoked.
|
||||||
|
markDone();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
window.addEventListener("pagehide", onPageHide, { once: true });
|
||||||
|
window.addEventListener("blur", onBlur, { once: true });
|
||||||
|
|
||||||
|
// If we're still here shortly after attempting navigation, assume it failed.
|
||||||
|
fallbackTimer = window.setTimeout(function () {
|
||||||
|
if (done) return;
|
||||||
|
window.location.href = fallbackHref;
|
||||||
|
}, 900);
|
||||||
|
|
||||||
|
window.location.href = geminiHref;
|
||||||
|
});
|
||||||
|
})(links[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", setupGeminiFallback);
|
||||||
|
} else {
|
||||||
|
setupGeminiFallback();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
JS
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def script_src(src)
|
def script_src(src)
|
||||||
|
|
@ -203,6 +293,55 @@ module Pressa
|
||||||
|
|
||||||
"#{start_year} - #{current_year}"
|
"#{start_year} - #{current_year}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def html_remote_links
|
||||||
|
site.html_output_options&.remote_links || []
|
||||||
|
end
|
||||||
|
|
||||||
|
def remote_nav_links
|
||||||
|
html_remote_links
|
||||||
|
end
|
||||||
|
|
||||||
|
def remote_link_href(href)
|
||||||
|
return href if href.match?(/\A[a-z][a-z0-9+\-.]*:/i)
|
||||||
|
|
||||||
|
absolute_asset(href)
|
||||||
|
end
|
||||||
|
|
||||||
|
def remote_link_class(link)
|
||||||
|
slug = link.icon || link.label.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/^-|-$/, "")
|
||||||
|
"remote-link #{slug}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def remote_link_icon_markup(link)
|
||||||
|
# Gemini doesn't have an obvious, widely-recognized protocol icon.
|
||||||
|
# Use a simple custom SVG mark so it aligns like the other SVG icons.
|
||||||
|
if link.icon == "gemini"
|
||||||
|
return <<~SVG.strip
|
||||||
|
<svg class="icon icon-gemini-protocol" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
<path transform="translate(12 12) scale(0.84 1.04) translate(-12 -12)" d="M18,5.3C19.35,4.97 20.66,4.54 21.94,4L21.18,2.14C18.27,3.36 15.15,4 12,4C8.85,4 5.73,3.38 2.82,2.17L2.06,4C3.34,4.54 4.65,4.97 6,5.3V18.7C4.65,19.03 3.34,19.46 2.06,20L2.82,21.86C8.7,19.42 15.3,19.42 21.18,21.86L21.94,20C20.66,19.46 19.35,19.03 18,18.7V5.3M8,18.3V5.69C9.32,5.89 10.66,6 12,6C13.34,6 14.68,5.89 16,5.69V18.31C13.35,17.9 10.65,17.9 8,18.31V18.3Z"/>
|
||||||
|
</svg>
|
||||||
|
SVG
|
||||||
|
end
|
||||||
|
|
||||||
|
icon_renderer = remote_link_icon_renderer(link.icon)
|
||||||
|
return nil unless icon_renderer
|
||||||
|
|
||||||
|
Icons.public_send(icon_renderer)
|
||||||
|
end
|
||||||
|
|
||||||
|
def remote_link_icon_renderer(icon)
|
||||||
|
case icon
|
||||||
|
when "mastodon" then :mastodon
|
||||||
|
when "github" then :github
|
||||||
|
when "rss" then :rss
|
||||||
|
when "code" then :code
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mastodon_link?(link)
|
||||||
|
link.icon == "mastodon"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ header.primary .title {
|
||||||
|
|
||||||
header.primary h1,
|
header.primary h1,
|
||||||
header.primary h4 {
|
header.primary h4 {
|
||||||
display: inline-block;
|
display: block;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|
@ -173,6 +173,7 @@ header.primary h4 {
|
||||||
header.primary h1 {
|
header.primary h1 {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
header.primary h1 a,
|
header.primary h1 a,
|
||||||
|
|
@ -180,6 +181,8 @@ header.primary h1 a:visited {
|
||||||
color: #f7f7f7;
|
color: #f7f7f7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* (protocol-link styles removed; Gemini now renders in the remote icon row) */
|
||||||
|
|
||||||
header.primary h4 {
|
header.primary h4 {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|
@ -264,6 +267,15 @@ header.primary nav ul li.github .icon {
|
||||||
color: #4183c4;
|
color: #4183c4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header.primary nav ul li.gemini a,
|
||||||
|
header.primary nav ul li.gemini a:visited {
|
||||||
|
color: #bdbdbd;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.primary nav ul li.gemini a:hover {
|
||||||
|
color: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
padding: 0 env(safe-area-inset-right) 2rem env(safe-area-inset-left);
|
padding: 0 env(safe-area-inset-right) 2rem env(safe-area-inset-left);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
|
||||||
33
site.toml
33
site.toml
|
|
@ -3,6 +3,7 @@ email = "sami@samhuri.net"
|
||||||
title = "samhuri.net"
|
title = "samhuri.net"
|
||||||
description = "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days."
|
description = "Sami Samhuri's blog about programming, mainly about iOS and Ruby and Rails these days."
|
||||||
url = "https://samhuri.net"
|
url = "https://samhuri.net"
|
||||||
|
fediverse_creator = "@sjs@techhub.social"
|
||||||
image_url = "/images/me.jpg"
|
image_url = "/images/me.jpg"
|
||||||
scripts = []
|
scripts = []
|
||||||
styles = ["/css/normalize.css", "/css/style.css", "/css/syntax.css"]
|
styles = ["/css/normalize.css", "/css/style.css", "/css/syntax.css"]
|
||||||
|
|
@ -16,3 +17,35 @@ scripts = [
|
||||||
"/js/projects.js"
|
"/js/projects.js"
|
||||||
]
|
]
|
||||||
styles = []
|
styles = []
|
||||||
|
|
||||||
|
[outputs.html]
|
||||||
|
remote_links = [
|
||||||
|
{"label": "Mastodon", "href": "https://techhub.social/@sjs", "icon": "mastodon"},
|
||||||
|
{"label": "GitHub", "href": "https://github.com/samsonjs", "icon": "github"},
|
||||||
|
{"label": "RSS", "href": "/feed.xml", "icon": "rss"},
|
||||||
|
{"label": "Gemini", "href": "gemini://samhuri.net", "icon": "gemini"}
|
||||||
|
]
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
recent_posts_limit = 20
|
||||||
|
home_links = [
|
||||||
|
"/about",
|
||||||
|
"/posts",
|
||||||
|
"/projects",
|
||||||
|
"https://techhub.social/@sjs",
|
||||||
|
"https://github.com/samsonjs",
|
||||||
|
"/posts/feed.gmi",
|
||||||
|
"https://samhuri.net/feed.xml",
|
||||||
|
"mailto:sami@samhuri.net"
|
||||||
|
]
|
||||||
|
exclude_public = [
|
||||||
|
"tweets/**",
|
||||||
|
"css/**",
|
||||||
|
"js/**",
|
||||||
|
"apple-touch-icon.png",
|
||||||
|
"favicon.ico",
|
||||||
|
"favicon.gif",
|
||||||
|
"robots.txt",
|
||||||
|
"humans.txt",
|
||||||
|
"isitmycakeday.html"
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class Pressa::Config::LoaderTest < Minitest::Test
|
||||||
assert_equal("https://samhuri.net", site.url)
|
assert_equal("https://samhuri.net", site.url)
|
||||||
assert_equal("https://samhuri.net/images/me.jpg", site.image_url)
|
assert_equal("https://samhuri.net/images/me.jpg", site.image_url)
|
||||||
assert_equal(["/css/style.css"], site.styles.map(&:href))
|
assert_equal(["/css/style.css"], site.styles.map(&:href))
|
||||||
|
assert_equal(["Mastodon", "GitHub"], site.html_output_options&.remote_links&.map(&:label))
|
||||||
|
|
||||||
projects_plugin = site.plugins.find { |plugin| plugin.is_a?(Pressa::Projects::Plugin) }
|
projects_plugin = site.plugins.find { |plugin| plugin.is_a?(Pressa::Projects::Plugin) }
|
||||||
refute_nil(projects_plugin)
|
refute_nil(projects_plugin)
|
||||||
|
|
@ -29,6 +30,209 @@ class Pressa::Config::LoaderTest < Minitest::Test
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_build_site_supports_gemini_output_format
|
||||||
|
with_temp_config do |dir|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
site = loader.build_site(output_format: "gemini")
|
||||||
|
|
||||||
|
assert_equal("gemini", site.output_format)
|
||||||
|
assert(site.plugins.any? { |plugin| plugin.is_a?(Pressa::Posts::GeminiPlugin) })
|
||||||
|
assert(site.plugins.any? { |plugin| plugin.is_a?(Pressa::Projects::GeminiPlugin) })
|
||||||
|
assert_equal(["Pressa::Utils::GeminiMarkdownRenderer"], site.renderers.map(&:class).map(&:name))
|
||||||
|
assert_equal(["tweets/**"], site.public_excludes)
|
||||||
|
assert_equal(20, site.gemini_output_options&.recent_posts_limit)
|
||||||
|
assert_equal(["/about/", "https://github.com/samsonjs"], site.gemini_output_options&.home_links&.map(&:href))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_rejects_invalid_output_excludes
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
exclude_public = [123]
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
error = assert_raises(Pressa::Config::ValidationError) do
|
||||||
|
loader.build_site(output_format: "gemini")
|
||||||
|
end
|
||||||
|
assert_match(/exclude_public\[0\] to be a non-empty String/, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_rejects_legacy_output_key
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
|
||||||
|
[output.gemini]
|
||||||
|
exclude_public = ["tweets/**"]
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
|
||||||
|
assert_match(/Legacy key 'output' is no longer supported/, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_rejects_legacy_social_url_keys
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
mastodon_url = "https://example.social/@sami"
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
|
||||||
|
assert_match(/Legacy keys 'mastodon_url'\/'github_url' are no longer supported/, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_accepts_gemini_home_links
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
plugins = ["posts"]
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
recent_posts_limit = 15
|
||||||
|
home_links = [
|
||||||
|
{"label": "About", "href": "/about/"},
|
||||||
|
{"label": "GitHub", "href": "https://github.com/samsonjs"}
|
||||||
|
]
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
site = loader.build_site(output_format: "gemini")
|
||||||
|
|
||||||
|
assert_equal(15, site.gemini_output_options&.recent_posts_limit)
|
||||||
|
assert_equal(["About", "GitHub"], site.gemini_output_options&.home_links&.map(&:label))
|
||||||
|
assert_equal(["/about/", "https://github.com/samsonjs"], site.gemini_output_options&.home_links&.map(&:href))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_rejects_invalid_gemini_home_links
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
home_links = [{"label": "About"}]
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
|
||||||
|
assert_match(/outputs\.gemini\.home_links\[0\]\.href to be a non-empty String/, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_rejects_invalid_recent_posts_limit
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
recent_posts_limit = 0
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
|
||||||
|
assert_match(/outputs\.gemini\.recent_posts_limit to be a positive Integer/, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_rejects_invalid_html_remote_links
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
|
||||||
|
[outputs.html]
|
||||||
|
remote_links = [{"label": "GitHub", "href": "github.com/samsonjs"}]
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site }
|
||||||
|
assert_match(/outputs\.html\.remote_links\[0\]\.href to start with \//, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_rejects_unknown_gemini_output_keys
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
something_else = true
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
|
||||||
|
assert_match(/Unknown key\(s\) in site\.toml outputs\.gemini: something_else/, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_build_site_rejects_unknown_gemini_home_link_keys
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
home_links = [{"label": "About", "href": "/about/", "icon": "mastodon"}]
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
error = assert_raises(Pressa::Config::ValidationError) { loader.build_site(output_format: "gemini") }
|
||||||
|
assert_match(/Unknown key\(s\) in site\.toml outputs\.gemini\.home_links\[0\]: icon/, error.message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def test_build_site_raises_a_validation_error_for_missing_required_site_keys
|
def test_build_site_raises_a_validation_error_for_missing_required_site_keys
|
||||||
Dir.mktmpdir do |dir|
|
Dir.mktmpdir do |dir|
|
||||||
File.write(File.join(dir, "site.toml"), "title = \"x\"\n")
|
File.write(File.join(dir, "site.toml"), "title = \"x\"\n")
|
||||||
|
|
@ -357,6 +561,35 @@ class Pressa::Config::LoaderTest < Minitest::Test
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_build_site_allows_string_home_links_and_optional_labels_for_gemini
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
home_links = [
|
||||||
|
"/about/",
|
||||||
|
{"href": "/posts/"},
|
||||||
|
{"label": "GitHub", "href": "https://github.com/samsonjs"}
|
||||||
|
]
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), "projects = []\n")
|
||||||
|
|
||||||
|
loader = Pressa::Config::Loader.new(source_path: dir)
|
||||||
|
site = loader.build_site(output_format: "gemini")
|
||||||
|
|
||||||
|
assert_equal("gemini", site.output_format)
|
||||||
|
assert_equal(3, site.gemini_output_options&.home_links&.length)
|
||||||
|
assert_nil(site.gemini_output_options&.home_links&.at(0)&.label)
|
||||||
|
assert_nil(site.gemini_output_options&.home_links&.at(1)&.label)
|
||||||
|
assert_equal("GitHub", site.gemini_output_options&.home_links&.at(2)&.label)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def with_temp_config
|
def with_temp_config
|
||||||
|
|
@ -375,6 +608,17 @@ class Pressa::Config::LoaderTest < Minitest::Test
|
||||||
[projects_plugin]
|
[projects_plugin]
|
||||||
scripts = ["/js/projects.js"]
|
scripts = ["/js/projects.js"]
|
||||||
styles = []
|
styles = []
|
||||||
|
|
||||||
|
[outputs.html]
|
||||||
|
remote_links = [
|
||||||
|
{"label": "Mastodon", "href": "https://techhub.social/@sjs", "icon": "mastodon"},
|
||||||
|
{"label": "GitHub", "href": "https://github.com/samsonjs", "icon": "github"}
|
||||||
|
]
|
||||||
|
|
||||||
|
[outputs.gemini]
|
||||||
|
recent_posts_limit = 20
|
||||||
|
home_links = ["/about/", "https://github.com/samsonjs"]
|
||||||
|
exclude_public = ["tweets/**"]
|
||||||
TOML
|
TOML
|
||||||
|
|
||||||
File.write(File.join(dir, "projects.toml"), <<~TOML)
|
File.write(File.join(dir, "projects.toml"), <<~TOML)
|
||||||
|
|
|
||||||
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")
|
assert_includes(details_html, "css/projects.css")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_gemini_plugin_renders_index_and_project_pages
|
||||||
|
gemini_site = Pressa::Site.new(
|
||||||
|
author: "Sami Samhuri",
|
||||||
|
email: "sami@samhuri.net",
|
||||||
|
title: "samhuri.net",
|
||||||
|
description: "blog",
|
||||||
|
url: "https://samhuri.net",
|
||||||
|
output_format: "gemini",
|
||||||
|
output_options: Pressa::GeminiOutputOptions.new
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin = Pressa::Projects::GeminiPlugin.new(projects: [project])
|
||||||
|
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
plugin.render(site: gemini_site, target_path: dir)
|
||||||
|
|
||||||
|
index_path = File.join(dir, "projects/index.gmi")
|
||||||
|
details_path = File.join(dir, "projects/demo/index.gmi")
|
||||||
|
assert(File.exist?(index_path))
|
||||||
|
assert(File.exist?(details_path))
|
||||||
|
|
||||||
|
index_text = File.read(index_path)
|
||||||
|
details_text = File.read(details_path)
|
||||||
|
|
||||||
|
assert_includes(index_text, "## Demo")
|
||||||
|
assert_includes(index_text, "Demo project")
|
||||||
|
assert_includes(index_text, "=> https://github.com/samsonjs/demo")
|
||||||
|
refute_includes(index_text, "=> /projects/demo/")
|
||||||
|
|
||||||
|
assert_includes(details_text, "=> https://github.com/samsonjs/demo")
|
||||||
|
assert_includes(details_text, "=> /projects/ Back to projects")
|
||||||
|
refute_includes(details_text, "Project link")
|
||||||
|
refute_includes(details_text, "Read on the web")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,20 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_gemini_site(plugin:, renderer:, public_excludes: [])
|
||||||
|
Pressa::Site.new(
|
||||||
|
author: "Sami Samhuri",
|
||||||
|
email: "sami@samhuri.net",
|
||||||
|
title: "samhuri.net",
|
||||||
|
description: "blog",
|
||||||
|
url: "https://samhuri.net",
|
||||||
|
plugins: [plugin],
|
||||||
|
renderers: [renderer],
|
||||||
|
output_format: "gemini",
|
||||||
|
output_options: Pressa::GeminiOutputOptions.new(public_excludes:)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def build_posts_by_year(year:)
|
def build_posts_by_year(year:)
|
||||||
post = Pressa::Posts::Post.new(
|
post = Pressa::Posts::Post.new(
|
||||||
slug: "first-post",
|
slug: "first-post",
|
||||||
|
|
@ -161,4 +175,24 @@ class Pressa::SiteGeneratorRenderingTest < Minitest::Test
|
||||||
assert_equal(2006, plugin.render_site_year)
|
assert_equal(2006, plugin.render_site_year)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_generate_skips_tweets_directory_for_gemini_output
|
||||||
|
Dir.mktmpdir do |root|
|
||||||
|
source_path = File.join(root, "source")
|
||||||
|
target_path = File.join(root, "target")
|
||||||
|
tweets_dir = File.join(source_path, "public", "tweets")
|
||||||
|
FileUtils.mkdir_p(tweets_dir)
|
||||||
|
File.write(File.join(tweets_dir, "index.html"), "<html>tweets</html>")
|
||||||
|
File.write(File.join(source_path, "public", "notes.md"), "# notes")
|
||||||
|
|
||||||
|
plugin = PluginSpy.new
|
||||||
|
renderer = MarkdownRendererSpy.new
|
||||||
|
site = build_gemini_site(plugin:, renderer:, public_excludes: ["tweets/**"])
|
||||||
|
|
||||||
|
Pressa::SiteGenerator.new(site:).generate(source_path:, target_path:)
|
||||||
|
|
||||||
|
refute(File.exist?(File.join(target_path, "tweets", "index.html")))
|
||||||
|
assert(File.exist?(File.join(target_path, "notes.html")))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,42 @@ class Pressa::SiteTest < Minitest::Test
|
||||||
assert_nil(site.image_url_for("/avatar.png"))
|
assert_nil(site.image_url_for("/avatar.png"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_site_defaults_to_html_output_options
|
||||||
|
site = Pressa::Site.new(
|
||||||
|
author: "Sami Samhuri",
|
||||||
|
email: "sami@samhuri.net",
|
||||||
|
title: "samhuri.net",
|
||||||
|
description: "blog",
|
||||||
|
url: "https://samhuri.net"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal("html", site.output_format)
|
||||||
|
assert_instance_of(Pressa::HTMLOutputOptions, site.output_options)
|
||||||
|
assert_equal([], site.html_output_options&.remote_links)
|
||||||
|
assert_nil(site.gemini_output_options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_output_option_helpers_match_gemini_site
|
||||||
|
site = Pressa::Site.new(
|
||||||
|
author: "Sami Samhuri",
|
||||||
|
email: "sami@samhuri.net",
|
||||||
|
title: "samhuri.net",
|
||||||
|
description: "blog",
|
||||||
|
url: "https://samhuri.net",
|
||||||
|
output_format: "gemini",
|
||||||
|
output_options: Pressa::GeminiOutputOptions.new(
|
||||||
|
public_excludes: ["tweets/**"],
|
||||||
|
recent_posts_limit: 12,
|
||||||
|
home_links: [Pressa::OutputLink.new(label: "About", href: "/about/")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_nil(site.html_output_options)
|
||||||
|
assert_instance_of(Pressa::GeminiOutputOptions, site.gemini_output_options)
|
||||||
|
assert_equal(["tweets/**"], site.public_excludes)
|
||||||
|
assert_equal(12, site.gemini_output_options&.recent_posts_limit)
|
||||||
|
end
|
||||||
|
|
||||||
def test_create_site_builds_site_using_loader
|
def test_create_site_builds_site_using_loader
|
||||||
Dir.mktmpdir do |dir|
|
Dir.mktmpdir do |dir|
|
||||||
File.write(File.join(dir, "site.toml"), <<~TOML)
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
|
@ -49,4 +85,22 @@ class Pressa::SiteTest < Minitest::Test
|
||||||
assert_equal("https://beta.samhuri.net", site.url)
|
assert_equal("https://beta.samhuri.net", site.url)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_create_site_accepts_output_format
|
||||||
|
Dir.mktmpdir do |dir|
|
||||||
|
File.write(File.join(dir, "site.toml"), <<~TOML)
|
||||||
|
author = "Sami Samhuri"
|
||||||
|
email = "sami@samhuri.net"
|
||||||
|
title = "samhuri.net"
|
||||||
|
description = "blog"
|
||||||
|
url = "https://samhuri.net"
|
||||||
|
TOML
|
||||||
|
File.write(File.join(dir, "projects.toml"), <<~TOML)
|
||||||
|
projects = []
|
||||||
|
TOML
|
||||||
|
|
||||||
|
site = Pressa.create_site(source_path: dir, output_format: "gemini")
|
||||||
|
assert_equal("gemini", site.output_format)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
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
|
end
|
||||||
|
|
||||||
|
def site_with_remote_links(links)
|
||||||
|
Pressa::Site.new(
|
||||||
|
author: "Sami Samhuri",
|
||||||
|
email: "sami@samhuri.net",
|
||||||
|
title: "samhuri.net",
|
||||||
|
description: "blog",
|
||||||
|
url: "https://samhuri.net",
|
||||||
|
output_options: Pressa::HTMLOutputOptions.new(remote_links: links)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def test_rendering_child_components_as_html_instead_of_escaped_text
|
def test_rendering_child_components_as_html_instead_of_escaped_text
|
||||||
html = Pressa::Views::Layout.new(
|
html = Pressa::Views::Layout.new(
|
||||||
site:,
|
site:,
|
||||||
|
|
@ -96,4 +107,37 @@ class Pressa::Views::LayoutTest < Minitest::Test
|
||||||
assert_includes(html, "<footer>© #{current_year} <a href=\"https://samhuri.net/about\">Sami Samhuri</a></footer>")
|
assert_includes(html, "<footer>© #{current_year} <a href=\"https://samhuri.net/about\">Sami Samhuri</a></footer>")
|
||||||
refute_includes(html, "<footer>© #{current_year} - #{current_year} ")
|
refute_includes(html, "<footer>© #{current_year} - #{current_year} ")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_remote_links_render_from_output_config
|
||||||
|
html = Pressa::Views::Layout.new(
|
||||||
|
site: site_with_remote_links([
|
||||||
|
Pressa::OutputLink.new(label: "Mastodon", href: "https://techhub.social/@sjs", icon: "mastodon"),
|
||||||
|
Pressa::OutputLink.new(label: "Gemini", href: "gemini://samhuri.net", icon: "gemini"),
|
||||||
|
Pressa::OutputLink.new(label: "GitHub", href: "https://github.com/samsonjs", icon: "github"),
|
||||||
|
Pressa::OutputLink.new(label: "RSS", href: "/feed.xml", icon: "rss")
|
||||||
|
]),
|
||||||
|
canonical_url: "https://samhuri.net/posts/",
|
||||||
|
content: content_view
|
||||||
|
).call
|
||||||
|
|
||||||
|
assert_includes(html, "href=\"https://techhub.social/@sjs\"")
|
||||||
|
assert_includes(html, "href=\"gemini://samhuri.net\"")
|
||||||
|
assert_includes(html, "href=\"https://github.com/samsonjs\"")
|
||||||
|
assert_includes(html, "href=\"https://samhuri.net/feed.xml\"")
|
||||||
|
assert_includes(html, "aria-label=\"Mastodon\"")
|
||||||
|
assert_includes(html, "aria-label=\"Gemini\"")
|
||||||
|
assert_includes(html, "aria-label=\"GitHub\"")
|
||||||
|
assert_includes(html, "aria-label=\"RSS\"")
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_missing_remote_links_do_not_render_hardcoded_profiles
|
||||||
|
html = Pressa::Views::Layout.new(
|
||||||
|
site:,
|
||||||
|
canonical_url: "https://samhuri.net/posts/",
|
||||||
|
content: content_view
|
||||||
|
).call
|
||||||
|
|
||||||
|
refute_includes(html, "techhub.social")
|
||||||
|
refute_includes(html, "github.com/samsonjs")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue