feat: add fediverse_creator handle for Mastodon attribution

Expose a per-post fediverse_creator frontmatter field plus a site-wide
default in config.toml ([site].fediverse_creator). The handle is returned
in the post and site payloads, rendered as <dc:creator> in the RSS feed
and as authors[].name in the JSON feed, so a front-end can drop it into
<meta name="fediverse:creator"> on rendered pages. Per-post value
overrides the site default. Validated against a simple @user@host regex.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-06-26 17:40:48 +02:00
co-authored by Qwen-Coder
parent 1440e9a1fd
commit 5437cef1cd
10 changed files with 250 additions and 8 deletions
+10
View File
@@ -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 `<dc:creator>` in the RSS feed, as `authors[].name`
in the JSON feed, and can be consumed by a front-end to emit
`<meta name="fediverse:creator">` on rendered pages. Per-post values
override the site default.
## [0.2.0] — 2026-06-25
### Added
+16
View File
@@ -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 `<dc:creator>` in
the RSS feed and `authors[].name` in the JSON feed. A front-end consuming the
API can drop it straight into `<head>`:
```html
<meta name="fediverse:creator" content="@petrbalvin@mastodon.social">
```
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.
+7 -1
View File
@@ -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": {},
+5
View File
@@ -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`.
+5 -1
View File
@@ -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 <meta name="fediverse:creator">
# by frontends consuming the API. Leave empty to disable.
fediverse_creator = ""
[admin]
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
+15
View File
@@ -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,
+34 -5
View File
@@ -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
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>#{escape(site["title"])}</title>
<link>#{escape(base_url)}</link>
@@ -165,12 +170,14 @@ module Volumen
else
""
end
creator = post.fediverse_creator
creator_tag = creator ? "\n <dc:creator>#{escape(creator)}</dc:creator>" : ""
<<~XML.chomp
<item>
<title>#{escape(post.title)}</title>
<link>#{escape(link)}</link>
<guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description>#{pub}
<description>#{escape(post.excerpt)}</description>#{pub}#{creator_tag}
</item>
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
+4
View File
@@ -16,6 +16,10 @@
</label>
<label>Language <input type="text" name="lang" value="<%= h(@post.lang) %>"></label>
<label>Author <input type="text" name="author" value="<%= h(@post.author) %>"></label>
<label>Fediverse creator
<input type="text" name="fediverse_creator" value="<%= h(@post.fediverse_creator) %>"
placeholder="@user@instance.tld">
</label>
<label>Date <input type="date" name="date" value="<%= h(@post.date_string) %>"></label>
<label>Publish at
<input type="date" name="publish_at"
+57
View File
@@ -77,4 +77,61 @@ class PostTest < Minitest::Test
post = Volumen::Post.new(metadata: {})
refute_predicate post, :scheduled?
end
def test_fediverse_creator_returns_metadata_value
post = Volumen::Post.new(
metadata: { "slug" => "x", "fediverse_creator" => "@user@example.social" }
)
assert_equal "@user@example.social", post.fediverse_creator
end
def test_fediverse_creator_is_nil_when_missing
post = Volumen::Post.new(metadata: { "slug" => "x" })
assert_nil post.fediverse_creator
end
def test_fediverse_creator_is_nil_when_blank
post = Volumen::Post.new(metadata: { "slug" => "x", "fediverse_creator" => " " })
assert_nil post.fediverse_creator
end
def test_fediverse_creator_validation_accepts_well_formed_handles
post = Volumen::Post.new(
metadata: { "slug" => "x", "fediverse_creator" => "@ada@lovelace.org" }
)
assert_predicate post, :valid_fediverse_creator?
end
def test_fediverse_creator_validation_rejects_malformed_handles
[
"ada@example.com",
"@only-one-at",
"@user@example.com extra",
"@user@",
"@@example.com"
].each do |value|
post = Volumen::Post.new(metadata: { "slug" => "x", "fediverse_creator" => value })
refute_predicate post, :valid_fediverse_creator?, "expected #{value.inspect} to be invalid"
end
end
def test_fediverse_creator_validation_accepts_padded_value
post = Volumen::Post.new(
metadata: { "slug" => "x", "fediverse_creator" => " @user@example.com " }
)
assert_predicate post, :valid_fediverse_creator?
end
def test_summary_includes_fediverse_creator
post = Volumen::Post.new(
metadata: { "slug" => "x", "fediverse_creator" => "@user@example.social" },
body: "body"
)
assert_equal "@user@example.social", post.summary["fediverse_creator"]
end
def test_summary_omits_fediverse_creator_when_absent
post = Volumen::Post.new(metadata: { "slug" => "x" }, body: "body")
assert_nil post.summary["fediverse_creator"]
end
end
+97 -1
View File
@@ -98,7 +98,7 @@ class ServerTest < Minitest::Test
assert_predicate last_response, :ok?
assert_equal "application/rss+xml", last_response["Content-Type"]&.split(";")&.first
body = last_response.body
assert_includes body, "<rss version=\"2.0\">"
assert_includes body, "<rss version=\"2.0\""
assert_includes body, "<title>Hello</title>"
refute_includes body, "Draft"
assert_includes body, "<language>en</language>"
@@ -154,6 +154,102 @@ class ServerTest < Minitest::Test
refute_includes data["items"].map { |i| i["title"] }, "Draft"
end
def test_post_summary_exposes_fediverse_creator
File.write(File.join(@dir, "attributed.md"), <<~POST)
+++
title = "Attributed"
slug = "attributed"
fediverse_creator = "@ada@lovelace.org"
+++
Body.
POST
get "/api/volumen/posts/attributed"
assert_predicate last_response, :ok?
data = JSON.parse(last_response.body)
assert_equal "@ada@lovelace.org", data["fediverse_creator"]
end
def test_post_summary_fediverse_creator_is_null_when_not_set
get "/api/volumen/posts/hello"
assert_predicate last_response, :ok?
assert_nil JSON.parse(last_response.body)["fediverse_creator"]
end
def test_posts_list_summary_exposes_fediverse_creator
File.write(File.join(@dir, "fed.md"), <<~POST)
+++
title = "Fed"
slug = "fed"
fediverse_creator = "@user@example.social"
+++
Body.
POST
get "/api/volumen/posts"
data = JSON.parse(last_response.body)
fed = data["posts"].find { |post| post["slug"] == "fed" }
assert_equal "@user@example.social", fed["fediverse_creator"]
end
def test_site_endpoint_exposes_fediverse_creator_default
get "/api/volumen/site"
assert_predicate last_response, :ok?
assert JSON.parse(last_response.body).key?("fediverse_creator")
end
def test_rss_feed_includes_dc_creator_when_set
File.write(File.join(@dir, "rss.md"), <<~POST)
+++
title = "Rss"
slug = "rss"
fediverse_creator = "@rss@example.com"
+++
Body.
POST
get "/api/volumen/feed.xml"
assert_includes last_response.body, "<dc:creator>@rss@example.com</dc:creator>"
assert_includes last_response.body, "xmlns:dc=\"http://purl.org/dc/elements/1.1/\""
end
def test_rss_feed_omits_dc_creator_when_not_set
get "/api/volumen/feed.xml"
refute_includes last_response.body, "<dc:creator>"
end
def test_json_feed_includes_authors_from_post
File.write(File.join(@dir, "jsonfed.md"), <<~POST)
+++
title = "Jsonfed"
slug = "jsonfed"
fediverse_creator = "@jf@example.io"
+++
Body.
POST
get "/api/volumen/feed.json"
item = JSON.parse(last_response.body)["items"].find { |i| i["id"] == "https://example.com/jsonfed" }
assert_equal [{ "name" => "@jf@example.io" }], item["authors"]
end
def test_json_feed_falls_back_to_site_default_fediverse_creator
Dir.chdir(@dir) do
File.write("config.toml", <<~TOML)
[site]
fediverse_creator = "@owner@example.com"
TOML
end
@config = Volumen::Config.load(
path: File.join(@dir, "config.toml"),
overrides: { content: @dir }
)
get "/api/volumen/feed.json"
items = JSON.parse(last_response.body)["items"]
expected = [{ "name" => "@owner@example.com" }]
assert(items.all? { |i| i["authors"] == expected })
end
def test_search_by_title
get "/api/volumen/posts?q=Hello"
data = JSON.parse(last_response.body)