diff --git a/CHANGELOG.md b/CHANGELOG.md index bafc09f..875a622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [development] +### Added + +- **Fediverse attribution:** a `fediverse_creator` frontmatter field (e.g. + `@user@mastodon.social`) plus a site-wide `[site].fediverse_creator` default + in `config.toml`. The value is exposed as `fediverse_creator` on post and + site payloads, rendered as `` in the RSS feed, as `authors[].name` + in the JSON feed, and can be consumed by a front-end to emit + `` on rendered pages. Per-post values + override the site default. + ## [0.2.0] — 2026-06-25 ### Added diff --git a/README.md b/README.md index 744fe7d..b5684c0 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ slug = "my-post" # optional, defaults to the filename date = 2026-01-15 # first-class TOML date lang = "en" # language code author = "Petr Balvín" # optional +fediverse_creator = "@petrbalvin@mastodon.social" # optional, for Mastodon attribution tags = ["ruby", "web"] # optional draft = false # optional excerpt = "Short summary" # optional, auto-derived from body if missing @@ -113,6 +114,21 @@ front-end can offer a language switcher. Set `all_langs = true` on a post to show it in **every** language (useful for posts that have no per-language translations). +### Fediverse attribution + +Set `fediverse_creator = "@user@instance.tld"` on a post to attach a Mastodon +handle to it. The value is exposed as `fediverse_creator` on the post payload +(`/api/volumen/posts/{slug}` and `/api/volumen/posts`), and as `` in +the RSS feed and `authors[].name` in the JSON feed. A front-end consuming the +API can drop it straight into ``: + +```html + +``` + +A site-wide default can be set in `config.toml` (`[site].fediverse_creator`); a +per-post value takes precedence. + ### Why TOML instead of YAML - **First-class dates** — `date = 2026-01-15` is a real date, not a guess. diff --git a/docs/api-reference.md b/docs/api-reference.md index ca728db..2e350aa 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -16,10 +16,14 @@ Returns site metadata from the configuration. "description": "A blog powered by volumen.", "base_url": "https://example.com", "language": "en", - "author": "Anonymous" + "author": "Anonymous", + "fediverse_creator": "@petrbalvin@mastodon.social" } ``` +`fediverse_creator` is `null` when the site-wide default is not set; a per-post +value (see below) takes precedence when present. + ## `GET /api/volumen/posts` Paginated list of published posts (drafts are excluded), newest first. @@ -46,6 +50,7 @@ Posts with `all_langs = true` in their frontmatter match every `lang` filter. "lang": "en", "tags": ["intro"], "author": null, + "fediverse_creator": null, "cover": null, "reading_time": 1, "translations": {}, @@ -70,6 +75,7 @@ no match. "lang": "en", "tags": ["intro"], "author": null, + "fediverse_creator": "@petrbalvin@mastodon.social", "cover": null, "reading_time": 1, "translations": {}, diff --git a/docs/configuration.md b/docs/configuration.md index 4749120..697ddab 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -22,6 +22,11 @@ description = "A blog powered by volumen." base_url = "https://example.com" language = "en" author = "Anonymous" +# Site-wide fediverse handle (e.g. Mastodon). Exposed as `fediverse_creator` +# on /api/volumen/site and used as the fallback in the RSS / JSON feeds when +# a post does not set its own `fediverse_creator` frontmatter field. Leave +# empty if you do not publish to the fediverse. +fediverse_creator = "@you@example.social" [admin] # scrypt hash produced by `volumen hash-password`. diff --git a/lib/volumen/config.rb b/lib/volumen/config.rb index 465b4a3..98eac7a 100644 --- a/lib/volumen/config.rb +++ b/lib/volumen/config.rb @@ -18,7 +18,8 @@ module Volumen "description" => "A blog powered by volumen.", "base_url" => "https://example.com", "language" => "en", - "author" => "Anonymous" + "author" => "Anonymous", + "fediverse_creator" => nil }.freeze, "admin" => { "password_hash" => "", @@ -46,6 +47,9 @@ module Volumen base_url = "https://example.com" language = "en" author = "Anonymous" + # Default fediverse handle surfaced in + # by frontends consuming the API. Leave empty to disable. + fediverse_creator = "" [admin] # Bootstrap admin login: paste a hash from `volumen hash-password` to sign diff --git a/lib/volumen/post.rb b/lib/volumen/post.rb index 1ee21cf..0833ea4 100644 --- a/lib/volumen/post.rb +++ b/lib/volumen/post.rb @@ -7,6 +7,8 @@ require_relative "markdown" module Volumen # A single blog post: TOML frontmatter metadata plus a Markdown body. class Post + FEDIVERSE_CREATOR_REGEX = /\A@[^@\s]+@[^@\s]+\z/ + attr_reader :metadata, :body attr_accessor :path @@ -56,6 +58,18 @@ module Volumen metadata["cover"] end + def fediverse_creator + value = metadata["fediverse_creator"] + return nil if value.nil? + + text = value.to_s.strip + text.empty? ? nil : text + end + + def valid_fediverse_creator? + fediverse_creator&.match?(FEDIVERSE_CREATOR_REGEX) + end + def publish_at metadata["publish_at"] end @@ -112,6 +126,7 @@ module Volumen "lang" => lang, "tags" => tags, "author" => author, + "fediverse_creator" => fediverse_creator, "cover" => cover, "reading_time" => reading_time, "translations" => translations, diff --git a/lib/volumen/server.rb b/lib/volumen/server.rb index 15d2b3d..9a4be0c 100644 --- a/lib/volumen/server.rb +++ b/lib/volumen/server.rb @@ -84,7 +84,12 @@ module Volumen end def site_payload - config.site.slice("title", "description", "base_url", "language", "author") + config.site.slice("title", "description", "base_url", "language", "author", + "fediverse_creator") + end + + def site_fediverse_creator + config.site["fediverse_creator"] end def posts_payload @@ -146,7 +151,7 @@ module Volumen items = published_posts.first(20).map { |post| feed_item(post) }.join("\n") <<~XML - + #{escape(site["title"])} #{escape(base_url)} @@ -165,12 +170,14 @@ module Volumen else "" end + creator = post.fediverse_creator + creator_tag = creator ? "\n #{escape(creator)}" : "" <<~XML.chomp #{escape(post.title)} #{escape(link)} #{escape(link)} - #{escape(post.excerpt)}#{pub} + #{escape(post.excerpt)}#{pub}#{creator_tag} XML end @@ -208,6 +215,8 @@ module Volumen "date_published" => iso_date(post.metadata["date"]), "tags" => post.tags } + author = post.fediverse_creator || site_fediverse_creator + item["authors"] = [{ "name" => author }] if author item.reject { |_k, v| v.nil? || v == "" || v == [] } end @@ -401,7 +410,15 @@ module Volumen existing = store.find(params["slug"]) halt(404, "Not found") if existing.nil? - store.save(post_from_params(params, existing: existing)) + post = post_from_params(params, existing: existing) + error = fediverse_creator_error(post) + if error + @mode = :edit + @post = post + @error = error + return erb(:form) + end + store.save(post) redirect to("/admin/") end @@ -410,7 +427,7 @@ module Volumen return "Invalid slug." unless post.slug.match?(SLUG_REGEX) return "A post with that slug already exists." if store.find(post.slug) - nil + fediverse_creator_error(post) end def post_from_params(http_params, existing: nil) @@ -419,6 +436,7 @@ module Volumen "slug" => presence(http_params["slug"]) || existing&.slug, "lang" => presence(http_params["lang"]), "author" => presence(http_params["author"]), + "fediverse_creator" => presence(http_params["fediverse_creator"]), "date" => parse_date(http_params["date"]), "publish_at" => parse_date(http_params["publish_at"]), "tags" => parse_tags(http_params["tags"]), @@ -432,6 +450,17 @@ module Volumen post end + def fediverse_creator_error(post) + value = post.metadata["fediverse_creator"] + return nil if value.nil? + + if Post::FEDIVERSE_CREATOR_REGEX.match?(value.to_s) + nil + else + "Fediverse creator must look like @user@host." + end + end + def clean_metadata(metadata) metadata.reject { |_key, value| value.nil? || value == "" || value == [] || value == false } end diff --git a/lib/volumen/web/views/form.erb b/lib/volumen/web/views/form.erb index b5d5e2f..e1160df 100644 --- a/lib/volumen/web/views/form.erb +++ b/lib/volumen/web/views/form.erb @@ -16,6 +16,10 @@ +