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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user