Initial commit
Test / test (push) Successful in 7m5s
Release / gates (push) Successful in 7m28s
Release / build (amd64, freebsd) (push) Successful in 2m52s
Release / build (amd64, linux) (push) Successful in 2m46s
Release / build (arm64, freebsd) (push) Successful in 2m22s
Release / build (arm64, linux) (push) Successful in 2m38s
Release / build (loong64, linux) (push) Successful in 2m7s
Release / build (riscv64, linux) (push) Successful in 2m17s
Release / release (push) Successful in 1m0s

Assisted-by: GLM 5.3
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
json "encoding/json/v2"
"fmt"
"strings"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
// Site is the /api/volumen/site payload: the [site] table as configured.
type Site struct {
Title string `json:"title"`
Description string `json:"description"`
BaseURL string `json:"base_url"`
Language string `json:"language"`
Author string `json:"author"`
FediverseCreator string `json:"fediverse_creator"`
}
// BuildSite reads the site block from the configuration.
func BuildSite(cfg *config.Config) Site {
return Site{
Title: cfg.Site.Title,
Description: cfg.Site.Description,
BaseURL: cfg.Site.BaseURL,
Language: cfg.Site.Language,
Author: cfg.Site.Author,
FediverseCreator: cfg.Site.FediverseCreator,
}
}
// Summary is the post shape used in list endpoints. Empty optional
// fields are omitted rather than sent as null.
type Summary struct {
Slug string `json:"slug,omitempty"`
Title string `json:"title,omitempty"`
Excerpt string `json:"excerpt"`
Date string `json:"date,omitempty"`
Lang string `json:"lang,omitempty"`
Tags []string `json:"tags,omitempty"`
Author string `json:"author,omitempty"`
FediverseCreator string `json:"fediverse_creator,omitempty"`
// DOI and ORCID carry the scholarly identifiers when the post has
// them: the bare identifier, normalised away from any doi.org URL or
// doi: prefix. A stored value that fails the syntax rule passes
// through untouched rather than vanishing from the read API.
DOI string `json:"doi,omitempty"`
ORCID string `json:"orcid,omitempty"`
Cover string `json:"cover,omitempty"`
CoverAlt string `json:"cover_alt,omitempty"`
CoverCaption string `json:"cover_caption,omitempty"`
ReadingTime int `json:"reading_time"`
Translations map[string]string `json:"translations,omitempty"`
Series string `json:"series,omitempty"`
SeriesOrder *int `json:"series_order,omitempty"`
URL string `json:"url"`
}
// Meta is the SEO block attached to a post detail.
type Meta struct {
URL string `json:"url"`
JSONLD string `json:"json_ld"`
OG map[string]any `json:"og"`
Twitter map[string]any `json:"twitter"`
}
// Detail is Summary plus the body, rendered HTML, table of contents and
// SEO metadata.
type Detail struct {
Summary
Body string `json:"body"`
HTML string `json:"html"`
TOC string `json:"toc"`
Meta Meta `json:"meta"`
// Fields carries the frontmatter keys outside post.ReservedMetadata,
// the author's own. A post whose frontmatter holds only known keys
// omits the member.
Fields map[string]any `json:"fields,omitzero"`
// References is the structured bibliography from the post's refs
// frontmatter, resolved to identifiers; omitted when the post cites
// nothing.
References []biblio.Entry `json:"references,omitzero"`
}
// CountedName is one entry of the tag cloud and the series list.
type CountedName struct {
Name string `json:"name"`
Count int `json:"count"`
}
// TagList is the /api/volumen/tags payload.
type TagList struct {
Tags []CountedName `json:"tags"`
}
// SeriesList is the /api/volumen/series payload.
type SeriesList struct {
Series []CountedName `json:"series"`
}
// SeriesDetail is the /api/volumen/series/{name} payload.
type SeriesDetail struct {
Name string `json:"name"`
Count int `json:"count"`
Posts []Summary `json:"posts"`
}
// Batch is the /api/volumen/posts/batch payload.
type Batch struct {
Posts []Detail `json:"posts"`
}
// PostListBase carries the fields every paginated post list shares.
type PostListBase struct {
PageSize int `json:"page_size"`
Total int `json:"total"`
Posts []Summary `json:"posts"`
}
// PageList is a page-numbered post list: it carries the page number and
// the has_next/has_prev flags, and no cursor.
type PageList struct {
PostListBase
Page int `json:"page"`
HasNext bool `json:"has_next"`
HasPrev bool `json:"has_prev"`
}
// CursorList is a cursor-paginated post list; next_cursor is always
// present and is null when the cursor reached the end.
type CursorList struct {
PostListBase
NextCursor *string `json:"next_cursor"`
}
// BuildSummary maps a post onto its API shape.
func BuildSummary(p *post.Post) Summary {
summary := Summary{
Slug: p.Slug(),
Title: p.Title(),
Excerpt: p.Excerpt(),
Date: p.DateString(),
Lang: p.Lang(),
Tags: p.Tags(),
Author: p.Author(),
FediverseCreator: p.FediverseCreator(),
DOI: displayDOI(p.DOI()),
ORCID: displayORCID(p.ORCID()),
Cover: p.Cover(),
CoverAlt: p.CoverAlt(),
CoverCaption: p.CoverCaption(),
ReadingTime: p.ReadingTime(),
Translations: p.Translations(),
Series: p.Series(),
URL: fmt.Sprintf("/api/volumen/posts/%s", p.Slug()),
}
if order, ok := p.SeriesOrder(); ok {
summary.SeriesOrder = &order
}
return summary
}
// displayDOI normalises a stored DOI for the API: the bare identifier
// when it is recognisable, the stored text trimmed when it is not, so a
// hand-edited value is never silently dropped.
func displayDOI(stored string) string {
bare := identifiers.NormalizeDOI(stored)
if bare == "" {
return ""
}
if !identifiers.ValidDOI(bare) {
return strings.TrimSpace(stored)
}
return bare
}
// displayORCID upper-cases a stored iD and keeps the raw text when the
// shape is not an iD at all.
func displayORCID(stored string) string {
id := identifiers.NormalizeORCID(stored)
if id == "" {
return ""
}
if !identifiers.ValidORCID(id) {
return strings.TrimSpace(stored)
}
return id
}
// BuildDetail maps a post onto its detail shape.
func BuildDetail(p *post.Post, baseURL string) (Detail, error) {
htmlOut, err := p.HTML()
if err != nil {
return Detail{}, err
}
toc, err := p.TOC()
if err != nil {
return Detail{}, err
}
meta, err := BuildMeta(p, baseURL)
if err != nil {
return Detail{}, err
}
return Detail{
Summary: BuildSummary(p),
Body: p.Body,
HTML: htmlOut,
TOC: toc,
Meta: meta,
Fields: p.CustomFields(),
References: p.RefsLinked(),
}, nil
}
// BuildMeta builds the SEO and discovery metadata: a Schema.org Article
// JSON-LD document plus OpenGraph and Twitter card fields.
func BuildMeta(p *post.Post, baseURL string) (Meta, error) {
htmlOut, err := p.HTML()
if err != nil {
return Meta{}, err
}
plain := post.PlainText(htmlOut, 200)
base := trimTrailingSlash(baseURL)
url := "/api/volumen/posts/" + p.Slug()
if base != "" {
url = base + "/" + p.Slug()
}
title := p.Title()
if title == "" {
title = p.Slug()
}
description := p.Excerpt()
if description == "" {
description = plain
}
lang := p.Lang()
if lang == "" {
lang = "en"
}
dateStr := p.DateString()
article := map[string]any{
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"inLanguage": lang,
"datePublished": dateStr,
"dateModified": dateStr,
"url": url,
"mainEntityOfPage": map[string]any{"@type": "WebPage", "@id": url},
}
if author := p.Author(); author != "" {
person := map[string]any{"@type": "Person", "name": author}
if orcid := identifiers.ORCIDURL(p.ORCID()); orcid != "" {
person["identifier"] = orcid
}
article["author"] = person
} else if orcid := identifiers.ORCIDURL(p.ORCID()); orcid != "" {
article["author"] = map[string]any{
"@type": "Person",
"identifier": orcid,
}
}
if doi := identifiers.DOIURL(p.DOI()); doi != "" {
article["identifier"] = doi
}
if citations := biblio.Citations(p.RefsLinked()); len(citations) > 0 {
article["citation"] = citations
}
if cover := p.Cover(); cover != "" {
article["image"] = []any{cover}
}
if tags := p.Tags(); len(tags) > 0 {
article["keywords"] = tags
}
if creator := p.FediverseCreator(); creator != "" {
article["creator"] = map[string]any{"@type": "Person", "name": creator}
}
og := map[string]any{
"og:type": "article",
"og:title": title,
"og:description": description,
"og:url": url,
"og:locale": lang,
"article:published_time": dateStr,
}
if cover := p.Cover(); cover != "" {
og["og:image"] = cover
}
if tags := p.Tags(); len(tags) > 0 {
og["article:tag"] = tags
}
if author := p.Author(); author != "" {
og["article:author"] = author
}
twitter := map[string]any{
"twitter:card": "summary_large_image",
"twitter:title": title,
"twitter:description": description,
}
if cover := p.Cover(); cover != "" {
twitter["twitter:image"] = cover
}
if creator := p.FediverseCreator(); creator != "" {
twitter["twitter:creator"] = creator
}
jsonLD, err := json.Marshal(article, json.Deterministic(true))
if err != nil {
return Meta{}, fmt.Errorf("encode json-ld: %w", err)
}
return Meta{
URL: url,
JSONLD: string(jsonLD),
OG: og,
Twitter: twitter,
}, nil
}
func trimTrailingSlash(value string) string { return strings.TrimRight(value, "/") }
+172
View File
@@ -0,0 +1,172 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
"encoding/json"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/config"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
const dtoSample = `+++
title = "Ahoj světe"
slug = "ahoj-svete"
lang = "cs"
author = "Petr"
tags = ["go", "blog"]
date = 2026-08-18
series = "Série"
series_order = 2
cover = "/media/cover.webp"
cover_alt = "alt"
cover_caption = "caption"
fediverse_creator = "@petr@mastodon.social"
[translations]
en = "hello-world"
+++
# Hlavička
Tělo.
`
func TestBuildSummary(t *testing.T) {
s := BuildSummary(parsePost(t, dtoSample))
if s.Slug != "ahoj-svete" {
t.Fatalf("slug = %q", s.Slug)
}
if s.URL != "/api/volumen/posts/ahoj-svete" {
t.Fatalf("url = %q", s.URL)
}
if s.ReadingTime != 1 {
t.Fatalf("reading_time = %d", s.ReadingTime)
}
if s.SeriesOrder == nil || *s.SeriesOrder != 2 {
t.Fatalf("series_order = %v", s.SeriesOrder)
}
if len(s.Tags) != 2 || s.Tags[0] != "go" {
t.Fatalf("tags = %v", s.Tags)
}
if s.Translations["en"] != "hello-world" {
t.Fatalf("translations = %v", s.Translations)
}
if s.CoverAlt != "alt" {
t.Fatalf("cover_alt = %q", s.CoverAlt)
}
}
func TestBuildSummaryEmptyPostOmitsOptionals(t *testing.T) {
s := BuildSummary(post.New(nil, "body"))
if s.Slug != "" || s.Title != "" || s.Date != "" || s.Series != "" {
t.Fatalf("optional fields should be empty: %+v", s)
}
if len(s.Tags) != 0 || len(s.Translations) != 0 {
t.Fatalf("collections should be empty: %+v", s)
}
if s.SeriesOrder != nil {
t.Fatalf("series_order = %v, want nil", s.SeriesOrder)
}
// Marshal and confirm the empty optionals are omitted entirely.
raw, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal: %v", err)
}
for _, key := range []string{`"slug"`, `"title"`, `"date"`, `"tags"`, `"translations"`, `"series_order"`, `"cover"`} {
if strings.Contains(string(raw), key) {
t.Fatalf("%s should be omitted: %s", key, raw)
}
}
if !strings.Contains(string(raw), `"excerpt":"body"`) {
t.Fatalf("excerpt missing: %s", raw)
}
}
func TestBuildDetail(t *testing.T) {
d, err := BuildDetail(parsePost(t, dtoSample), "https://example.com/")
if err != nil {
t.Fatalf("BuildDetail: %v", err)
}
if d.HTML == "" || d.TOC == "" || d.Body == "" {
t.Fatalf("detail = %+v", d)
}
if d.Meta.URL != "https://example.com/ahoj-svete" {
t.Fatalf("meta url = %q", d.Meta.URL)
}
for _, want := range []string{
`"@context":"https://schema.org"`, `"@type":"Article"`,
`"headline":"Ahoj světe"`, `"inLanguage":"cs"`, `"datePublished":"2026-08-18"`,
} {
if !strings.Contains(d.Meta.JSONLD, want) {
t.Fatalf("json_ld missing %s: %s", want, d.Meta.JSONLD)
}
}
if d.Meta.OG["og:type"] != "article" || d.Meta.OG["og:image"] != "/media/cover.webp" {
t.Fatalf("og = %v", d.Meta.OG)
}
if d.Meta.Twitter["twitter:card"] != "summary_large_image" {
t.Fatalf("twitter = %v", d.Meta.Twitter)
}
}
func TestBuildMetaDescriptionFallback(t *testing.T) {
p := post.New(nil, "Body without an excerpt and with enough words to fill the description.")
meta, err := BuildMeta(p, "")
if err != nil {
t.Fatalf("BuildMeta: %v", err)
}
if meta.OG["og:description"] == "" || meta.OG["og:description"] == nil {
t.Fatal("og:description empty")
}
if meta.URL != "/api/volumen/posts/" {
t.Fatalf("url = %q", meta.URL)
}
}
func TestBuildSiteShape(t *testing.T) {
cfg, err := config.Load(t.TempDir()+"/none.toml", config.Overrides{Port: -1})
if err != nil {
t.Fatalf("config: %v", err)
}
site := BuildSite(cfg)
if site.Title != config.DefaultSiteTitle || site.Language != "en" {
t.Fatalf("site = %+v", site)
}
if site.FediverseCreator != "" {
t.Fatalf("fediverse_creator = %q, want empty", site.FediverseCreator)
}
}
func TestPlainTextTruncation(t *testing.T) {
long := "<p>" + strings.Repeat("word ", 100) + "</p>"
plain := post.PlainText(long, 20)
// Trailing spaces are trimmed before the ellipsis, so the result is
// at most limit+1 runes long.
if len([]rune(plain)) > 21 || !strings.HasSuffix(plain, "…") {
t.Fatalf("plain = %q (%d runes)", plain, len([]rune(plain)))
}
if plain := post.PlainText("<b>short</b>", 200); plain != "short" {
t.Fatalf("plain = %q", plain)
}
}
func TestSeriesOrderPointer(t *testing.T) {
meta := frontmatter.NewMeta()
meta.Set("series_order", int64(3))
s := BuildSummary(post.New(meta, ""))
if s.SeriesOrder == nil || *s.SeriesOrder != 3 {
t.Fatalf("series_order = %v", s.SeriesOrder)
}
meta2 := frontmatter.NewMeta()
meta2.Set("date", interpres.LocalDate{Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)})
if got := BuildSummary(post.New(meta2, "")).Date; got != "2026-01-02" {
t.Fatalf("date = %q", got)
}
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
// The write form normalises identifiers as it stores them: a doi.org
// URL becomes the bare DOI, an iD gains its upper-case X.
func TestIdentifiersNormaliseOnWrite(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "A paper",
"slug": "a-paper",
"body": "b",
"doi": "https://doi.org/10.5281/zenodo.1234567",
"orcid": "0000-0000-0000-001x",
}, nil)
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if got := p.DOI(); got != "10.5281/zenodo.1234567" {
t.Fatalf("stored doi = %q", got)
}
if got := p.ORCID(); got != "0000-0000-0000-001X" {
t.Fatalf("stored orcid = %q", got)
}
// Identifiers are reserved: they never appear in the custom fields.
if fields := p.CustomFields(); fields != nil {
t.Fatalf("CustomFields leaked identifiers: %v", fields)
}
s := newStoreWithPosts(t, nil)
if err := CreationError(p, s, nil); err != nil {
t.Fatalf("valid identifiers refused: %v", err)
}
}
func TestIdentifierValidationErrors(t *testing.T) {
s := newStoreWithPosts(t, nil)
base := func(key, value string) *post.Post {
meta := frontmatter.NewMeta()
meta.Set("slug", "x")
meta.Set("title", "T")
meta.Set(key, value)
return post.New(meta, "body")
}
cases := []struct {
key, value, want string
}{
{"doi", "20.1234/x", "DOI must look like 10.xxxx/suffix."},
{"doi", "10.1", "DOI must look like 10.xxxx/suffix."},
{"orcid", "0000-0002-1825-0098", "ORCID must look like 0000-0002-1825-0097."},
{"orcid", "not-an-orcid", "ORCID must look like 0000-0002-1825-0097."},
}
for _, tc := range cases {
err := CreationError(base(tc.key, tc.value), s, nil)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s %q: error = %v, want %q", tc.key, tc.value, err, tc.want)
}
}
// Both empty stay empty: the fields remain optional.
if err := CreationError(base("doi", ""), s, nil); err != nil {
t.Errorf("empty doi refused: %v", err)
}
}
// The read shapes carry the identifiers, and the SEO block publishes
// them: an identifier property on the article, one on its author.
func TestIdentifiersInPayloads(t *testing.T) {
meta := frontmatter.NewMeta()
meta.Set("slug", "paper")
meta.Set("title", "Paper")
meta.Set("author", "Petr Balvín")
meta.Set("doi", "https://doi.org/10.1000/xyz")
meta.Set("orcid", "0000-0002-1825-0097")
p := post.New(meta, "body text here")
summary := BuildSummary(p)
if summary.DOI != "10.1000/xyz" {
t.Fatalf("summary doi = %q", summary.DOI)
}
if summary.ORCID != "0000-0002-1825-0097" {
t.Fatalf("summary orcid = %q", summary.ORCID)
}
m, err := BuildMeta(p, "https://example.com")
if err != nil {
t.Fatalf("BuildMeta: %v", err)
}
for _, want := range []string{
`"identifier":"https://doi.org/10.1000/xyz"`,
`"identifier":"https://orcid.org/0000-0002-1825-0097"`,
} {
if !strings.Contains(m.JSONLD, want) {
t.Fatalf("json_ld missing %s:\n%s", want, m.JSONLD)
}
}
// A stored value that is no identifier at all survives the read
// untouched rather than vanishing.
broken := post.New(func() *frontmatter.Meta {
m := frontmatter.NewMeta()
m.Set("slug", "broken")
m.Set("doi", "10.ONE_HUNDRED/x")
return m
}(), "b")
if got := BuildSummary(broken).DOI; got != "10.ONE_HUNDRED/x" {
t.Fatalf("invalid stored doi dropped on read: %q", got)
}
}
+798
View File
@@ -0,0 +1,798 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package payloads builds the public API responses: filtering,
// pagination, tag clouds, series, and post validation helpers.
package payloads
import (
json "encoding/json/v2"
"fmt"
"log/slog"
"maps"
"regexp"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
"sourcedock.dev/petrbalvin/volumen/internal/markdown"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
var (
// SlugRegex accepts lowercase slugs with inner dots, dashes and
// underscores.
SlugRegex = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$`)
// LangRegex accepts simple language codes such as cs or pt-BR.
LangRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
// MaxSlugLength caps slug length.
const MaxSlugLength = 200
// MaxPageSize is the largest page the list endpoints return, and the
// documented limit.
const MaxPageSize = 100
// maxSeriesOrder is the sort key for a post that carries no
// series_order: it sorts after every explicit order.
const maxSeriesOrder = 1 << 30
// Source is what the payload builders read from a content store: the
// whole set for a listing, and one post by slug for a validation rule.
type Source interface {
All() []*post.Post
Find(slug, lang string) *post.Post
}
// PublishedPosts returns all published posts, newest first. A post that
// is a draft or still scheduled is withheld.
func PublishedPosts(s Source) []*post.Post {
var visible []*post.Post
for _, p := range s.All() {
if p.Published() {
visible = append(visible, p)
}
}
slices.SortStableFunc(visible, func(a, b *post.Post) int {
return strings.Compare(b.DateString(), a.DateString())
})
return visible
}
// FilterPosts filters published posts by lang, tag and query.
func FilterPosts(posts []*post.Post, lang, tag, query string) []*post.Post {
if lang != "" {
var out []*post.Post
for _, p := range posts {
if p.Lang() == lang || p.AllLangs() {
out = append(out, p)
}
}
posts = out
}
if tag != "" {
var out []*post.Post
for _, p := range posts {
if slices.Contains(p.Tags(), tag) {
out = append(out, p)
}
}
posts = out
}
query = strings.ToLower(strings.TrimSpace(query))
if query != "" {
scored := make([]scoredPost, 0, len(posts))
for _, p := range posts {
if score := searchScore(p, query); score > 0 {
scored = append(scored, scoredPost{post: p, score: score})
}
}
// Relevance first: the strongest match leads, and posts that
// score the same keep the date order they arrived in.
slices.SortStableFunc(scored, func(a, b scoredPost) int {
return b.score - a.score
})
posts = make([]*post.Post, len(scored))
for i, s := range scored {
posts[i] = s.post
}
}
return posts
}
// scoredPost pairs a post with the relevance it scored against the
// running query.
type scoredPost struct {
post *post.Post
score int
}
// searchScore ranks one post against the lowered query: a match higher
// in the post weighs more, so a title hit outranks a passing mention in
// the body. Zero means the post does not match at all and leaves the
// result list.
func searchScore(p *post.Post, query string) int {
score := 0
title := strings.ToLower(p.Title())
if strings.Contains(title, query) {
score += 100
if strings.HasPrefix(title, query) {
score += 50
}
}
for _, tag := range p.Tags() {
tag = strings.ToLower(tag)
switch {
case tag == query:
score += 40
case strings.Contains(tag, query):
score += 15
}
}
if strings.Contains(strings.ToLower(p.Excerpt()), query) {
score += 20
}
if hits := strings.Count(strings.ToLower(p.Body), query); hits > 0 {
score += 10 + min(hits-1, 4)*2
}
return score
}
// PostsPayload builds the paginated payload for /api/volumen/posts.
// With a cursor the list starts after that slug and page numbers are
// ignored; otherwise the list is page-numbered. The two shapes differ
// in which fields they carry, so they are distinct types. A cursor that
// names no post yields an empty page rather than silently rewinding to
// the first one, which would send a paging client posts it already has.
func PostsPayload(s Source, lang, tag, query string, page, limit int, cursor string) any {
posts := FilterPosts(PublishedPosts(s), lang, tag, query)
limit = min(max(limit, 1), MaxPageSize)
total := len(posts)
offset := 0
if cursor != "" {
offset = cursorOffset(posts, cursor)
} else {
page = max(page, 1)
offset = (page - 1) * limit
}
end := min(offset+limit, total)
var pagePosts []*post.Post
if offset < total {
pagePosts = posts[offset:end]
}
summaries := make([]Summary, 0, len(pagePosts))
for _, p := range pagePosts {
summaries = append(summaries, BuildSummary(p))
}
base := PostListBase{PageSize: limit, Total: total, Posts: summaries}
var nextCursor *string
if len(pagePosts) > 0 && offset+limit < total {
last := pagePosts[len(pagePosts)-1]
value := last.Slug()
if slugShared(posts, last.Slug()) {
// Two posts share the slug (translations do): name the exact
// post, or the next page resumes after the first variant and
// serves the second one twice.
value += "." + last.Lang()
}
nextCursor = &value
}
if cursor != "" {
return CursorList{PostListBase: base, NextCursor: nextCursor}
}
return PageList{
PostListBase: base,
Page: max(page, 1),
HasNext: offset+limit < total,
HasPrev: max(page, 1) > 1,
}
}
// cursorOffset resolves a cursor to the index the next page starts
// after. A cursor is "slug", or "slug.lang" when the slug is shared by
// translations: the composite form is matched first so an ambiguous
// slug cannot resume the walk at the wrong variant. A cursor that names
// no post yields the end, an empty page, rather than rewinding.
func cursorOffset(posts []*post.Post, cursor string) int {
for i, p := range posts {
if p.Slug()+"."+p.Lang() == cursor {
return i + 1
}
}
for i, p := range posts {
if p.Slug() == cursor {
return i + 1
}
}
return len(posts)
}
// slugShared reports whether more than one post in the list carries the
// slug.
func slugShared(posts []*post.Post, slug string) bool {
count := 0
for _, p := range posts {
if p.Slug() == slug {
count++
if count > 1 {
return true
}
}
}
return false
}
// IsEmpty reports whether a paginated payload carries no posts. A
// payload of an unknown type is not treated as empty, so a future shape
// cannot accidentally turn into a 404.
func IsEmpty(payload any) bool {
switch list := payload.(type) {
case PageList:
return len(list.Posts) == 0
case CursorList:
return len(list.Posts) == 0
}
return false
}
// BuildTagCounts counts the tags of the given posts, sorted by
// frequency then name. The caller chooses the post set, so the admin
// dashboard counts its own view (drafts included) and the API counts the
// published posts.
func BuildTagCounts(posts []*post.Post) []CountedName {
counts := map[string]int{}
for _, p := range posts {
for _, tag := range p.Tags() {
counts[tag]++
}
}
return orderedCounts(counts)
}
// BuildSeriesList lists series with their post counts, most posts
// first.
func BuildSeriesList(s Source) []CountedName {
counts := map[string]int{}
for _, p := range PublishedPosts(s) {
if p.Series() != "" {
counts[p.Series()]++
}
}
return orderedCounts(counts)
}
func orderedCounts(counts map[string]int) []CountedName {
// slices.SortedFunc takes the keys as an iterator, so the list is
// built and ordered in one step.
names := slices.SortedFunc(maps.Keys(counts), func(a, b string) int {
if counts[a] != counts[b] {
return counts[b] - counts[a]
}
return strings.Compare(a, b)
})
out := make([]CountedName, 0, len(names))
for _, name := range names {
out = append(out, CountedName{Name: name, Count: counts[name]})
}
return out
}
// SeriesPosts returns the published posts of one series, ordered by
// series_order then date then slug.
func SeriesPosts(s Source, name string) []*post.Post {
var posts []*post.Post
for _, p := range PublishedPosts(s) {
if p.Series() == name {
posts = append(posts, p)
}
}
slices.SortStableFunc(posts, func(a, b *post.Post) int {
orderA, okA := a.SeriesOrder()
if !okA {
orderA = maxSeriesOrder
}
orderB, okB := b.SeriesOrder()
if !okB {
orderB = maxSeriesOrder
}
if orderA != orderB {
return orderA - orderB
}
if c := strings.Compare(a.DateString(), b.DateString()); c != 0 {
return c
}
return strings.Compare(a.Slug(), b.Slug())
})
return posts
}
// Presence returns a stripped string, or "" when empty.
func Presence(value any) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", value))
}
// ParseDate parses an ISO 8601 date string.
func ParseDate(value any) (time.Time, bool) {
text := Presence(value)
if text == "" {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", text)
if err != nil {
return time.Time{}, false
}
return t, true
}
// ParseInt parses an integer from form data.
func ParseInt(value any) (int, bool) {
text := Presence(value)
if text == "" {
return 0, false
}
n, err := strconv.Atoi(text)
if err != nil {
return 0, false
}
return n, true
}
// ParseTags parses a comma-separated tag string.
func ParseTags(value any) []string {
raw := Presence(value)
if raw == "" {
return nil
}
var out []string
for tag := range strings.SplitSeq(raw, ",") {
if trimmed := strings.TrimSpace(tag); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
// PostFromParams builds a post from admin form data, carrying the
// existing post's path when editing. Metadata keys the form does not
// manage (aliases, translations, custom fields) are inherited from the
// existing post so an editor save never drops them. A date field the
// form carries but cannot parse is reported as a ValidationError on the
// built post: an unparseable value dropping the key would silently
// publish a scheduled post. The caller renders the returned post back
// into the form either way.
func PostFromParams(form map[string]string, existing *post.Post) (*post.Post, error) {
badDateField := ""
for _, field := range []string{"date", "publish_at"} {
if Presence(form[field]) != "" {
if _, ok := ParseDate(form[field]); !ok {
badDateField = field
}
}
}
badUTF8Field := ""
for _, field := range append(slices.Clone(metadataOrder), "body") {
if value, present := form[field]; present && !utf8.ValidString(value) {
badUTF8Field = field
break
}
}
m := frontmatter.NewMeta()
if existing != nil {
// The clone carries the comments and the nested shapes of the
// stored file: the form only rewrites its own fields, and every
// key it does not name round-trips untouched.
m = existing.Metadata.Clone()
}
cleaned := cleanMetadata(baseMetadata(form, existing))
for _, key := range metadataOrder {
if key == badDateField {
// The form value was rejected, so the field keeps its
// inherited value: the post goes back into the editor with
// the schedule it had, not with the bad input written into
// the metadata nor with the schedule silently dropped.
continue
}
value, present := cleaned[key]
if !present {
// Cleared in the form: drop any inherited value too.
m.Delete(key)
continue
}
m.Set(key, value)
}
p := post.New(m, form["body"])
if existing != nil {
p.Path = existing.Path
}
if badUTF8Field != "" {
// Saving would silently replace the invalid bytes with U+FFFD,
// so the form is rejected instead: the author sees the field that
// carries them and keeps control over the text.
return p, invalid(fmt.Sprintf("%s must be valid UTF-8 text.", badUTF8Field))
}
if badDateField != "" {
return p, invalid(fmt.Sprintf("%s must be an ISO 8601 date.", badDateField))
}
// The references arrive as one JSON field from the editor. An absent
// field means the form never carried one, and the stored list
// round-trips untouched; an empty list is an explicit deletion.
tables, present, err := refsFromForm(form)
if err != nil {
return p, err
}
if present {
if len(tables) == 0 {
m.Delete("refs")
} else {
m.Set("refs", tables)
}
}
return p, nil
}
var metadataOrder = []string{
"title", "slug", "lang", "author", "fediverse_creator",
"doi", "orcid",
"date", "publish_at", "tags", "excerpt", "cover", "cover_alt",
"cover_caption", "series", "series_order", "draft", "all_langs",
}
func baseMetadata(form map[string]string, existing *post.Post) map[string]any {
slug := Presence(form["slug"])
if slug == "" && existing != nil {
slug = existing.Slug()
}
meta := map[string]any{
"title": Presence(form["title"]),
"slug": slug,
"lang": Presence(form["lang"]),
"author": Presence(form["author"]),
"fediverse_creator": Presence(form["fediverse_creator"]),
"doi": identifiers.NormalizeDOI(form["doi"]),
"orcid": identifiers.NormalizeORCID(form["orcid"]),
"tags": ParseTags(form["tags"]),
"excerpt": Presence(form["excerpt"]),
"cover": Presence(form["cover"]),
"cover_alt": Presence(form["cover_alt"]),
"cover_caption": Presence(form["cover_caption"]),
"series": Presence(form["series"]),
}
if d, ok := ParseDate(form["date"]); ok {
meta["date"] = interpres.LocalDate{Time: d}
}
if d, ok := ParseDate(form["publish_at"]); ok {
meta["publish_at"] = interpres.LocalDate{Time: d}
}
if n, ok := ParseInt(form["series_order"]); ok {
meta["series_order"] = int64(n)
}
if form["draft"] == "on" {
meta["draft"] = true
}
if form["all_langs"] == "on" {
meta["all_langs"] = true
}
return meta
}
// cleanMetadata drops nil, empty and false entries, keeping the
// original key order.
func cleanMetadata(meta map[string]any) map[string]any {
out := make(map[string]any, len(meta))
for _, key := range metadataOrder {
value, ok := meta[key]
if !ok || value == nil {
continue
}
switch v := value.(type) {
case string:
if v == "" {
continue
}
case []string:
if len(v) == 0 {
continue
}
case bool:
if !v {
continue
}
}
out[key] = value
}
return out
}
// ValidationError is a user-facing validation failure. The message is
// shown verbatim in the API error envelope and in the admin forms.
type ValidationError struct {
Message string
}
func (e *ValidationError) Error() string { return e.Message }
func invalid(message string) error { return &ValidationError{Message: message} }
// The bounds of one reference list written from the editor. They exist so
// a runaway payload dies at a named rule rather than at the body limit.
const (
maxRefEntries = 500
maxRefField = 4000
maxRefAuthors = 200
)
// refsFromForm decodes the editor's reference list. The second return
// value reports whether the form carried the field at all: absent means
// the stored list survives untouched, present means the decoded list (or
// its deletion) is the author's explicit choice. A row that carries
// nothing at all is dropped rather than saved as an empty table. The
// tables come back as []map[string]any, the shape the frontmatter writer
// renders as [[refs]] blocks rather than one inline array.
func refsFromForm(form map[string]string) ([]map[string]any, bool, error) {
encoded, present := form["refs"]
if !present {
return nil, false, nil
}
if strings.TrimSpace(encoded) == "" {
return nil, true, nil
}
var entries []map[string]any
if err := json.Unmarshal([]byte(encoded), &entries); err != nil {
return nil, true, invalid("References must be a list of entries.")
}
if len(entries) > maxRefEntries {
return nil, true, invalid(fmt.Sprintf("References must hold at most %d entries.", maxRefEntries))
}
out := make([]map[string]any, 0, len(entries))
for i, entry := range entries {
table, err := cleanRefEntry(entry, i+1)
if err != nil {
return nil, true, err
}
if table != nil {
out = append(out, table)
}
}
return out, true, nil
}
// cleanRefEntry validates one reference table and returns it in the
// canonical shape the frontmatter writer accepts: empty strings dropped,
// identifiers normalised, authors as strings or name tables. A row whose
// every field is blank returns nil, meaning it is dropped.
func cleanRefEntry(entry map[string]any, position int) (map[string]any, error) {
which := fmt.Sprintf("Reference %d", position)
clip := func(value any, field string) (string, error) {
var text string
switch v := value.(type) {
case string:
text = v
case float64:
// A year or volume the client sent as a JSON number still
// belongs in the table, as the string the model stores.
if v == float64(int64(v)) {
text = strconv.FormatInt(int64(v), 10)
} else {
text = strconv.FormatFloat(v, 'f', -1, 64)
}
}
text = strings.TrimSpace(text)
if len(text) > maxRefField {
return "", invalid(fmt.Sprintf("%s: %s must be at most %d characters.", which, field, maxRefField))
}
return text, nil
}
out := map[string]any{}
for _, field := range []string{"raw", "title", "venue", "year", "volume", "pages", "arxiv", "url"} {
text, err := clip(entry[field], field)
if err != nil {
return nil, err
}
if text != "" {
out[field] = text
}
}
if doi, _ := entry["doi"].(string); strings.TrimSpace(doi) != "" {
doi = identifiers.NormalizeDOI(doi)
if !identifiers.ValidDOI(doi) {
return nil, invalid(fmt.Sprintf("%s: DOI must look like 10.xxxx/suffix.", which))
}
out["doi"] = doi
}
if arxiv, ok := out["arxiv"]; ok {
id := strings.TrimPrefix(strings.TrimPrefix(arxiv.(string), "https://arxiv.org/abs/"), "arXiv:")
if id == "" || strings.ContainsAny(id, " \t\"'<>") {
return nil, invalid(fmt.Sprintf("%s: arXiv must be the bare identifier, e.g. 2401.12345.", which))
}
out["arxiv"] = id
}
if urlField, ok := out["url"]; ok {
if !strings.HasPrefix(urlField.(string), "http://") && !strings.HasPrefix(urlField.(string), "https://") {
return nil, invalid(fmt.Sprintf("%s: URL must be an http(s) address.", which))
}
}
authors, err := cleanRefAuthors(entry["authors"], which)
if err != nil {
return nil, err
}
if len(authors) > 0 {
out["authors"] = authors
}
// An explicit number survives a round trip, so a hand-numbered list
// keeps its numbering through the editor.
switch n := entry["num"].(type) {
case float64:
if n == float64(int(n)) && n >= 1 && n <= 9999 {
out["num"] = int64(n)
}
case string:
if parsed, err := strconv.Atoi(strings.TrimSpace(n)); err == nil && parsed >= 1 && parsed <= 9999 {
out["num"] = int64(parsed)
}
}
// A row with only identifiers and no citation of its own would render
// as an empty entry; an entirely blank row is simply dropped.
if _, cited := out["raw"]; !cited {
if _, ok := out["title"]; !ok {
if _, hasAuthors := out["authors"]; !hasAuthors {
if len(out) == 0 {
return nil, nil
}
return nil, invalid(fmt.Sprintf("%s needs the citation itself: the verbatim line, the title, or the authors.", which))
}
}
}
return out, nil
}
// cleanRefAuthors validates the author list of one reference: plain name
// strings, or name tables with an optional ORCID checked to its digit.
func cleanRefAuthors(value any, which string) ([]any, error) {
list, ok := value.([]any)
if !ok {
return nil, nil
}
if len(list) > maxRefAuthors {
return nil, invalid(fmt.Sprintf("%s: at most %d authors per reference.", which, maxRefAuthors))
}
out := make([]any, 0, len(list))
for _, item := range list {
switch author := item.(type) {
case string:
if name := strings.TrimSpace(author); name != "" {
out = append(out, name)
}
case map[string]any:
name, _ := author["name"].(string)
name = strings.TrimSpace(name)
if name == "" {
continue
}
orcid, _ := author["orcid"].(string)
orcid = identifiers.NormalizeORCID(strings.TrimSpace(orcid))
if orcid != "" && !identifiers.ValidORCID(orcid) {
return nil, invalid(fmt.Sprintf("%s: ORCID must look like 0000-0002-1825-0097.", which))
}
if orcid != "" {
out = append(out, map[string]any{"name": name, "orcid": orcid})
} else {
out = append(out, name)
}
}
}
return out, nil
}
// CreationError validates a post before saving; nil means valid.
func CreationError(p *post.Post, s Source, existing *post.Post) error {
if Presence(p.Slug()) == "" {
return invalid("Slug is required.")
}
slug := p.Slug()
if len([]rune(slug)) > MaxSlugLength {
return invalid(fmt.Sprintf("Slug must be at most %d characters.", MaxSlugLength))
}
if !SlugRegex.MatchString(slug) {
return invalid("Invalid slug.")
}
if lang := p.Lang(); lang != "" && !LangRegex.MatchString(lang) {
return invalid("Invalid language.")
}
if len(p.Body) > markdown.MaxBodyLength {
return invalid(fmt.Sprintf("Body must be at most %d bytes.", markdown.MaxBodyLength))
}
if found := s.Find(slug, ""); found != nil &&
(existing == nil || found.Path != existing.Path) {
return invalid("A post with that slug already exists.")
}
if err := FediverseCreatorError(p); err != nil {
return err
}
if err := DOIError(p); err != nil {
return err
}
return ORCIDError(p)
}
// FediverseCreatorError validates the optional fediverse handle.
func FediverseCreatorError(p *post.Post) error {
value, ok := p.Metadata.Get("fediverse_creator")
if !ok || value == nil {
return nil
}
if fediverse.Valid(fmt.Sprintf("%v", value)) {
return nil
}
return invalid("Fediverse creator must look like @user@host.")
}
// DOIError validates the optional DOI. The stored value is the bare
// form; a doi.org URL or doi: prefix normalises away before the rule.
func DOIError(p *post.Post) error {
value, ok := p.Metadata.Get("doi")
if !ok || value == nil {
return nil
}
text := fmt.Sprintf("%v", value)
if text == "" || identifiers.ValidDOI(text) {
return nil
}
return invalid("DOI must look like 10.xxxx/suffix.")
}
// ORCIDError validates the optional ORCID iD, check digit included.
func ORCIDError(p *post.Post) error {
value, ok := p.Metadata.Get("orcid")
if !ok || value == nil {
return nil
}
text := fmt.Sprintf("%v", value)
if text == "" || identifiers.ValidORCID(text) {
return nil
}
return invalid("ORCID must look like 0000-0002-1825-0097.")
}
// Repository is what the payload builders write through.
type Repository interface {
Source
Save(p *post.Post) (*post.Post, error)
Delete(slug, lang string) (*post.Post, bool, error)
}
// SavePost persists a post through the repository. When the slug changed
// since existing, the file moves to the path its new slug implies and
// the old file is soft-deleted, so a rename is one operation with one
// undo and one revision archive. The admin and the API both save through
// here, so the on-disk result never depends on which one asked.
func SavePost(st Repository, p, existing *post.Post) (*post.Post, error) {
renamed := existing != nil && existing.Path != "" && p.Slug() != "" && p.Slug() != existing.Slug()
if renamed {
p.Path = ""
}
saved, err := st.Save(p)
if err != nil {
return nil, err
}
if renamed {
if _, _, err := st.Delete(existing.Slug(), existing.Lang()); err != nil {
slog.Warn("payloads: could not archive the post under its old slug",
"slug", existing.Slug(), "error", err)
}
}
return saved, nil
}
+508
View File
@@ -0,0 +1,508 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
"sourcedock.dev/petrbalvin/volumen/internal/store"
)
// An edit through the form only rewrites the fields the form names: a
// key the form does not carry keeps the comment written above it.
func TestPostFromParamsKeepsUntouchedComments(t *testing.T) {
existing, _, err := frontmatter.Parse("+++\ntitle = \"Old\"\nslug = \"old\"\nlang = \"en\"\n\n# chapters live here\n[[chapters]]\nx = 1\n+++\n\nbody\n")
if err != nil {
t.Fatalf("Parse: %v", err)
}
p, err := PostFromParams(map[string]string{
"title": "New", "slug": "old", "lang": "en",
}, post.New(existing, "body\n"))
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if p.Title() != "New" {
t.Fatalf("title = %q, want New", p.Title())
}
out, err := frontmatter.Dump(p.Metadata, "body\n")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.Contains(out, "# chapters live here") || !strings.Contains(out, "[[chapters]]") {
t.Fatalf("untouched frontmatter lost its comment:\n%s", out)
}
}
// Every form field the editor saves is a key the engine consumes, so
// none of them may leak into the API's fields object: this guards the
// two lists against drifting apart.
func TestMetadataOrderStaysReserved(t *testing.T) {
for _, key := range metadataOrder {
if !post.ReservedMetadata[key] {
t.Fatalf("form key %q is not in post.ReservedMetadata", key)
}
}
}
func newStoreWithPosts(t *testing.T, files map[string]string) *store.Store {
t.Helper()
dir := filepath.Join(t.TempDir(), "posts")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
for name, body := range files {
target := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(target, []byte(body), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}
return store.New(store.Options{ContentDir: dir, DefaultLang: "en", RevisionLimit: 10})
}
const publishedFile = `+++
title = "Published"
slug = "published"
date = 2026-08-18
lang = "cs"
tags = ["go", "blog"]
series = "S"
series_order = 2
+++
published body
`
const draftFile = `+++
title = "Draft"
slug = "draft"
draft = true
+++
draft body
`
const scheduledFile = `+++
title = "Future"
slug = "future"
publish_at = 2999-01-01
+++
future body
`
func TestPublishedPostsExcludesDraftsAndScheduled(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"published.md": publishedFile,
"draft.md": draftFile,
"future.md": scheduledFile,
})
posts := PublishedPosts(s)
if len(posts) != 1 || posts[0].Slug() != "published" {
t.Fatalf("published = %v", posts)
}
}
func TestPublishedPostsSortsNewestFirst(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"old.md": "+++\nslug = \"old\"\ndate = 2020-01-01\n+++\nx\n",
"new.md": "+++\nslug = \"new\"\ndate = 2026-01-01\n+++\nx\n",
})
posts := PublishedPosts(s)
if len(posts) != 2 || posts[0].Slug() != "new" {
t.Fatalf("order = %v, %v", posts[0].Slug(), posts[1].Slug())
}
}
func TestFilterPosts(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"a.md": "+++\nslug = \"a\"\nlang = \"cs\"\ntags = [\"go\"]\ntitle = \"Go tipy\"\n+++\nbody here\n",
"b.md": "+++\nslug = \"b\"\nlang = \"en\"\ntags = [\"life\"]\ntitle = \"Hello\"\n+++\nother\n",
"c.md": "+++\nslug = \"c\"\nall_langs = true\nlang = \"cs\"\ntags = [\"go\"]\ntitle = \"CS GO\"\n+++\nxx\n",
})
all := PublishedPosts(s)
if got := FilterPosts(all, "en", "", ""); len(got) != 2 {
t.Fatalf("lang filter = %v", slugs(got))
}
if got := FilterPosts(all, "", "go", ""); len(got) != 2 {
t.Fatalf("tag filter = %v", slugs(got))
}
if got := FilterPosts(all, "", "", "body here"); len(got) != 1 || got[0].Slug() != "a" {
t.Fatalf("query filter = %v", slugs(got))
}
if got := FilterPosts(all, "", "", "HELLO"); len(got) != 1 || got[0].Slug() != "b" {
t.Fatalf("case-insensitive query = %v", slugs(got))
}
}
func parsePost(t *testing.T, content string) *post.Post {
t.Helper()
p, err := post.Parse(content)
if err != nil {
t.Fatalf("parse: %v", err)
}
return p
}
func slugs(posts []*post.Post) []string {
out := make([]string, len(posts))
for i, p := range posts {
out[i] = p.Slug()
}
return out
}
func TestPostsPayloadPagination(t *testing.T) {
files := map[string]string{}
for i, slug := range []string{"one", "two", "three", "four", "five"} {
files[slug+".md"] = "+++\nslug = \"" + slug + "\"\ntitle = \"" + slug +
"\"\ndate = 2026-01-0" + string(rune('1'+i)) + "\n+++\nx\n"
}
s := newStoreWithPosts(t, files)
first, ok := PostsPayload(s, "", "", "", 1, 2, "").(PageList)
if !ok {
t.Fatal("page mode must return a PageList")
}
if first.Total != 5 || first.Page != 1 || !first.HasNext || first.HasPrev {
t.Fatalf("first page = %+v", first)
}
if len(first.Posts) != 2 {
t.Fatalf("posts = %v", first.Posts)
}
if !first.HasNext {
t.Fatalf("first page must have a next page: %+v", first)
}
last, ok := PostsPayload(s, "", "", "", 3, 2, "").(PageList)
if !ok {
t.Fatal("page mode must return a PageList")
}
if last.HasNext || !last.HasPrev {
t.Fatalf("last page = %+v", last)
}
cursorPage, ok := PostsPayload(s, "", "", "", 1, 2, "five").(CursorList)
if !ok {
t.Fatal("cursor mode must return a CursorList")
}
if cursorPage.NextCursor == nil {
t.Fatalf("cursor page = %+v", cursorPage)
}
}
func TestPostsPayloadLimitClamping(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"a.md": "+++\nslug = \"a\"\n+++\nx\n",
})
huge, ok := PostsPayload(s, "", "", "", 1, 5000, "").(PageList)
if !ok || huge.PageSize != 100 {
t.Fatalf("page_size = %v, want 100", huge.PageSize)
}
zero, ok := PostsPayload(s, "", "", "", 1, 0, "").(PageList)
if !ok || zero.PageSize != 1 {
t.Fatalf("page_size = %v, want 1", zero.PageSize)
}
}
func TestPostsPayloadIsEmpty(t *testing.T) {
s := newStoreWithPosts(t, nil)
if !IsEmpty(PostsPayload(s, "", "", "", 1, 20, "")) {
t.Fatal("empty store must yield an empty payload")
}
if IsEmpty("not a payload") {
t.Fatal("unknown types count as empty")
}
}
func TestTagCountsAndSeries(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"a.md": "+++\nslug = \"a\"\ntags = [\"go\", \"blog\"]\nseries = \"S\"\nseries_order = 2\ndate = 2026-01-02\n+++\nx\n",
"b.md": "+++\nslug = \"b\"\ntags = [\"go\"]\nseries = \"S\"\nseries_order = 1\ndate = 2026-01-03\n+++\nx\n",
"c.md": "+++\nslug = \"c\"\ntags = [\"zz\"]\nseries = \"T\"\ndate = 2026-01-04\n+++\nx\n",
})
tags := BuildTagCounts(PublishedPosts(s))
if len(tags) != 3 || tags[0].Name != "go" || tags[0].Count != 2 {
t.Fatalf("tags = %v", tags)
}
series := BuildSeriesList(s)
if len(series) != 2 || series[0].Name != "S" || series[0].Count != 2 {
t.Fatalf("series = %v", series)
}
posts := SeriesPosts(s, "S")
if len(posts) != 2 || posts[0].Slug() != "b" || posts[1].Slug() != "a" {
t.Fatalf("series posts = %v", slugs(posts))
}
}
func TestSeriesPostsOrderFallbacks(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"a.md": "+++\nslug = \"a\"\nseries = \"S\"\ndate = 2026-01-02\n+++\nx\n",
"b.md": "+++\nslug = \"b\"\nseries = \"S\"\ndate = 2026-01-01\n+++\nx\n",
})
posts := SeriesPosts(s, "S")
// No series_order: date ascending.
if posts[0].Slug() != "b" || posts[1].Slug() != "a" {
t.Fatalf("order = %v", slugs(posts))
}
}
func TestPresenceAndParsers(t *testing.T) {
if Presence(" x ") != "x" || Presence(nil) != "" || Presence(" ") != "" {
t.Fatal("Presence wrong")
}
if _, ok := ParseDate("2026-08-18"); !ok {
t.Fatal("ParseDate failed")
}
if _, ok := ParseDate("nonsense"); ok {
t.Fatal("ParseDate accepted nonsense")
}
if _, ok := ParseDate(""); ok {
t.Fatal("ParseDate accepted empty")
}
if n, ok := ParseInt(" 42 "); !ok || n != 42 {
t.Fatalf("ParseInt = %d, %v", n, ok)
}
if _, ok := ParseInt("x"); ok {
t.Fatal("ParseInt accepted nonsense")
}
if _, ok := ParseInt(""); ok {
t.Fatal("ParseInt accepted empty")
}
tags := ParseTags("go, blog ,,rust")
if len(tags) != 3 || tags[1] != "blog" {
t.Fatalf("ParseTags = %v", tags)
}
if ParseTags("") != nil {
t.Fatal("ParseTags empty should be nil")
}
}
func TestPostFromParams(t *testing.T) {
form := map[string]string{
"title": "Nadpis",
"slug": "nadpis",
"lang": "cs",
"author": "Petr",
"fediverse_creator": "@petr@social",
"date": "2026-08-18",
"publish_at": "",
"tags": "go, blog",
"excerpt": "Perex",
"cover": "/media/c.webp",
"cover_alt": "alt",
"cover_caption": "caption",
"series": "S",
"series_order": "3",
"draft": "on",
"all_langs": "on",
"body": "obsah",
}
p, err := PostFromParams(form, nil)
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if p.Slug() != "nadpis" || p.Title() != "Nadpis" || p.Body != "obsah" {
t.Fatalf("post = %v", p)
}
if !p.Draft() || !p.AllLangs() {
t.Fatal("flags not set")
}
if p.DateString() != "2026-08-18" {
t.Fatalf("date = %q", p.DateString())
}
if order, ok := p.SeriesOrder(); !ok || order != 3 {
t.Fatalf("series_order = %d, %v", order, ok)
}
if _, present := p.Metadata.Get("publish_at"); present {
t.Fatal("empty publish_at kept")
}
if len(p.Tags()) != 2 {
t.Fatalf("tags = %v", p.Tags())
}
// Cleaned metadata drops empties and keeps date as a bare TOML date.
out, err := p.ToFile()
if err != nil {
t.Fatalf("ToFile: %v", err)
}
if !strings.Contains(out, "date = 2026-08-18") {
t.Fatalf("date not written bare:\n%s", out)
}
if strings.Contains(out, "excerpt = \"\"") {
t.Fatalf("empty values kept:\n%s", out)
}
}
func TestPostFromParamsKeepsExistingPathAndSlug(t *testing.T) {
existing := parsePost(t, "+++\nslug = \"old\"\n+++\nx\n")
p, err := PostFromParams(map[string]string{"title": "T", "body": "b"}, existing)
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if p.Slug() != "old" {
t.Fatalf("slug = %q, want fallback to existing", p.Slug())
}
if p.Path != existing.Path {
t.Fatalf("path not carried: %q", p.Path)
}
}
func TestCreationError(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"taken.md": "+++\nslug = \"taken\"\n+++\nx\n",
})
newPost := func(slug, lang, fediverse string) *post.Post {
meta := frontmatter.NewMeta()
meta.Set("slug", slug)
if lang != "" {
meta.Set("lang", lang)
}
if fediverse != "" {
meta.Set("fediverse_creator", fediverse)
}
return post.New(meta, "x")
}
check := func(err error, want string) {
t.Helper()
if want == "" {
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
return
}
validation, ok := errors.AsType[*ValidationError](err)
if err == nil || !ok {
t.Fatalf("err = %v, want a ValidationError", err)
}
if validation.Message != want {
t.Fatalf("message = %q, want %q", validation.Message, want)
}
}
check(CreationError(newPost("", "", ""), s, nil), "Slug is required.")
check(CreationError(newPost("Upper!", "", ""), s, nil), "Invalid slug.")
if err := CreationError(newPost(strings.Repeat("a", MaxSlugLength+1), "", ""), s, nil); err == nil {
t.Fatal("want slug length error")
}
check(CreationError(newPost("ok", "bad lang!", ""), s, nil), "Invalid language.")
check(CreationError(newPost("taken", "", ""), s, nil), "A post with that slug already exists.")
// Editing the same post keeps its own slug.
existing := s.Find("taken", "")
check(CreationError(newPost("taken", "", ""), s, existing), "")
check(CreationError(newPost("fresh", "", "not-a-handle"), s, nil),
"Fediverse creator must look like @user@host.")
check(CreationError(newPost("fresh", "cs", "@ok@host"), s, nil), "")
check(CreationError(newPost("a.b_c-d", "", ""), s, nil), "")
}
func TestSlugAndLangRegexes(t *testing.T) {
if SlugRegex.MatchString("-leading") || SlugRegex.MatchString("trailing-") || SlugRegex.MatchString("UPPER") {
t.Fatal("SlugRegex too permissive")
}
if !SlugRegex.MatchString("a") || !SlugRegex.MatchString("a.b-c_d") {
t.Fatal("SlugRegex too strict")
}
if LangRegex.MatchString("cs CS") || !LangRegex.MatchString("pt-BR") {
t.Fatal("LangRegex wrong")
}
}
func TestLocalDateDumpShape(t *testing.T) {
// Guard: form dates must serialise as bare TOML dates, not RFC3339.
d := time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC)
meta := frontmatter.NewMeta()
meta.Set("date", interpres.LocalDate{Time: d})
out, err := frontmatter.Dump(meta, "x")
if err != nil {
t.Fatalf("Dump: %v", err)
}
if !strings.Contains(out, "date = 2026-03-04") {
t.Fatalf("out = %s", out)
}
}
// A malformed date in the form must be rejected rather than dropped:
// dropping it would delete an inherited schedule and publish the post.
func TestPostFromParamsRejectsAMalformedDate(t *testing.T) {
existing := parsePost(t, "+++\nslug = \"s\"\ntitle = \"T\"\npublish_at = 2999-01-01\n+++\nx\n")
p, err := PostFromParams(map[string]string{"title": "T", "publish_at": "not a date", "body": "b"}, existing)
if err == nil {
t.Fatal("a malformed publish_at was accepted")
}
if want := "publish_at must be an ISO 8601 date."; err.Error() != want {
t.Fatalf("error = %q, want %q", err.Error(), want)
}
// The post comes back for the form re-render; the schedule is kept
// because the form value never reached the metadata.
if _, ok := p.DueAt(); !ok {
t.Fatal("the inherited schedule was dropped on a rejected form")
}
}
// Two published posts may share a slug across languages; the cursor must
// then name the exact post or the walk serves the second variant twice
// and, with a tight limit, never advances.
func TestCursorPaginationAcrossSameSlugTranslations(t *testing.T) {
s := newStoreWithPosts(t, map[string]string{
"en/shared.md": "+++\nslug = \"shared\"\ntitle = \"EN\"\nlang = \"en\"\ndate = 2026-01-03\n+++\nx\n",
"cs/shared.md": "+++\nslug = \"shared\"\ntitle = \"CS\"\nlang = \"cs\"\ndate = 2026-01-02\n+++\nx\n",
"other.md": "+++\nslug = \"other\"\ntitle = \"O\"\ndate = 2026-01-01\n+++\nx\n",
})
seen := map[string]bool{}
// The walk starts after the first variant, as it would for a client
// that paged one post at a time and just received the EN variant.
cursor := "shared"
pages := 0
for {
payload := PostsPayload(s, "", "", "", 1, 1, cursor).(CursorList)
for _, post := range payload.Posts {
key := post.Slug + "|" + post.Lang
if seen[key] {
t.Fatalf("post %s served twice", key)
}
seen[key] = true
}
pages++
if payload.NextCursor == nil {
break
}
cursor = *payload.NextCursor
if pages > 10 {
t.Fatal("the cursor walk does not terminate")
}
}
if len(seen) != 2 {
t.Fatalf("walked %d posts, want the CS variant and one more: %v", len(seen), seen)
}
}
// Invalid UTF-8 typed into a form field is rejected rather than saved:
// the writer would otherwise silently replace the bytes with U+FFFD.
func TestPostFromParamsRejectsInvalidUTF8(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "Bad \xff\xfe", "slug": "utf8-test", "body": "ok",
}, nil)
if err == nil {
t.Fatal("invalid UTF-8 was accepted")
}
if want := "title must be valid UTF-8 text."; err.Error() != want {
t.Fatalf("error = %q, want %q", err.Error(), want)
}
if p == nil {
t.Fatal("the post must come back for the form re-render")
}
// A clean body alone is fine.
if _, err := PostFromParams(map[string]string{"title": "Ok", "body": "ok"}, nil); err != nil {
t.Fatalf("clean form rejected: %v", err)
}
}
+231
View File
@@ -0,0 +1,231 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package payloads
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
"sourcedock.dev/petrbalvin/volumen/internal/post"
)
const refsPost = `+++
title = "Cite"
slug = "cite"
date = 2026-08-18
[[refs]]
title = "Observational evidence from supernovae"
authors = [{ name = "Riess, A. G.", orcid = "0000-0002-1825-0097" }]
venue = "The Astronomical Journal"
year = 1998
doi = "10.1103/PhysRevD.59.103502"
+++
Tvrzení [1].
`
func TestDetailCarriesReferences(t *testing.T) {
p := parsePost(t, refsPost)
d, err := BuildDetail(p, "https://example.com")
if err != nil {
t.Fatalf("BuildDetail: %v", err)
}
if len(d.References) != 1 {
t.Fatalf("references = %d", len(d.References))
}
ref := d.References[0]
if ref.Title == "" || ref.DOI != "10.1103/PhysRevD.59.103502" ||
ref.Authors[0].ORCID != "0000-0002-1825-0097" {
t.Fatalf("reference = %+v", ref)
}
// The JSON-LD block cites the work back with author and identifier.
for _, want := range []string{
`"citation"`, `"ScholarlyArticle"`,
`https://doi.org/10.1103/PhysRevD.59.103502`,
`https://orcid.org/0000-0002-1825-0097`,
} {
if !strings.Contains(d.Meta.JSONLD, want) {
t.Fatalf("json-ld missing %q:\n%s", want, d.Meta.JSONLD)
}
}
// The HTML carries the linked citation too.
if !strings.Contains(d.HTML, `href="#ref-1"`) {
t.Fatalf("html missing the citation anchor:\n%s", d.HTML)
}
}
// A post without refs omits the member entirely: the wire shape of
// every ordinary post is unchanged.
func TestDetailOmitsEmptyReferences(t *testing.T) {
p := parsePost(t, "+++\ntitle = \"Plain\"\nslug = \"plain\"\n+++\nx\n")
d, err := BuildDetail(p, "https://example.com")
if err != nil {
t.Fatalf("BuildDetail: %v", err)
}
if len(d.References) != 0 {
t.Fatalf("references = %v", d.References)
}
if strings.Contains(d.Meta.JSONLD, "citation") {
t.Fatalf("plain post gained citations:\n%s", d.Meta.JSONLD)
}
}
// When the cited work is published in the same instance, the detail
// exposes the internal link in every shape the consumer reads: the
// rendered HTML, the references member, and the JSON-LD citation.
func TestReferencesCarryInternalLink(t *testing.T) {
p := parsePost(t, refsPost)
p.SetLinkIndex(map[string]string{"10.1103/physrevd.59.103502": "riess-1998"})
d, err := BuildDetail(p, "https://example.com")
if err != nil {
t.Fatalf("BuildDetail: %v", err)
}
if d.References[0].Internal != "/api/volumen/posts/riess-1998" {
t.Fatalf("references internal = %+v", d.References[0])
}
if !strings.Contains(d.HTML, `href="/api/volumen/posts/riess-1998"`) {
t.Fatalf("html missing the internal link:\n%s", d.HTML)
}
if !strings.Contains(d.Meta.JSONLD, "/api/volumen/posts/riess-1998") {
t.Fatalf("json-ld missing the internal url:\n%s", d.Meta.JSONLD)
}
// The canonical DOI identifier stays in the JSON-LD beside it.
if !strings.Contains(d.Meta.JSONLD, "https://doi.org/10.1103/PhysRevD.59.103502") {
t.Fatalf("canonical identifier lost:\n%s", d.Meta.JSONLD)
}
}
func existingWithRefs(t *testing.T) *post.Post {
t.Helper()
m := frontmatter.NewMeta()
m.Set("title", "Hello")
m.Set("slug", "hello")
// The shape a parsed file carries: an array of tables.
m.Set("refs", []map[string]any{
{"raw": "Riess, A. G. et al. 1998, AJ 116, 1009"},
})
return post.New(m, "body")
}
func refTables(t *testing.T, p *post.Post) []map[string]any {
t.Helper()
raw, ok := p.Metadata.Get("refs")
if !ok {
return nil
}
list, ok := raw.([]map[string]any)
if !ok {
t.Fatalf("refs = %T, want []map[string]any", raw)
}
return list
}
// A form that carries no refs field leaves the stored list untouched: a
// save from a page without its bibliography card still round-trips the
// file as it stands.
func TestRefsAbsentFromFormKeepsTheStoredList(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "Hello", "slug": "hello", "body": "new",
}, existingWithRefs(t))
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
tables := refTables(t, p)
if len(tables) != 1 || tables[0]["raw"] != "Riess, A. G. et al. 1998, AJ 116, 1009" {
t.Fatalf("refs = %v", tables)
}
}
// An empty list is an explicit deletion, not a lost field.
func TestRefsEmptyListDeletesTheKey(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "Hello", "slug": "hello", "body": "b", "refs": "[]",
}, existingWithRefs(t))
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
if _, ok := p.Metadata.Get("refs"); ok {
t.Fatal("refs survived an explicit empty list")
}
}
// The editor's JSON becomes the frontmatter tables: values trimmed,
// identifiers normalised, author ORCIDs kept as name tables, an explicit
// number preserved, a blank row dropped.
func TestRefsFormWritesCanonicalTables(t *testing.T) {
p, err := PostFromParams(map[string]string{
"title": "Hello", "slug": "hello", "body": "b",
"refs": `[
{"raw": " Trimmed raw line. "},
{},
{"num": 2,
"authors": [{"name": "Adam Riess", "orcid": "0000-0002-1825-0097"}, "plain author"],
"title": "Observational Evidence from Supernovae",
"venue": "The Astronomical Journal", "year": "1998",
"volume": "116", "pages": "1009",
"doi": "https://doi.org/10.1086/300499",
"arxiv": "arXiv:astro-ph/9805201",
"url": "https://example.com/paper"}
]`,
}, existingWithRefs(t))
if err != nil {
t.Fatalf("PostFromParams: %v", err)
}
tables := refTables(t, p)
if len(tables) != 2 {
t.Fatalf("tables = %v", tables)
}
if tables[0]["raw"] != "Trimmed raw line." {
t.Fatalf("raw = %q", tables[0]["raw"])
}
second := tables[1]
if second["num"] != int64(2) {
t.Fatalf("num = %v", second["num"])
}
if second["doi"] != "10.1086/300499" {
t.Fatalf("doi = %q", second["doi"])
}
if second["arxiv"] != "astro-ph/9805201" {
t.Fatalf("arxiv = %q", second["arxiv"])
}
authors, ok := second["authors"].([]any)
if !ok || len(authors) != 2 {
t.Fatalf("authors = %v", second["authors"])
}
first, ok := authors[0].(map[string]any)
if !ok || first["name"] != "Adam Riess" || first["orcid"] != "0000-0002-1825-0097" {
t.Fatalf("author = %v", authors[0])
}
if authors[1] != "plain author" {
t.Fatalf("second author = %v", authors[1])
}
}
func TestRefsFormRejectsBadPayloads(t *testing.T) {
cases := []struct {
name string
refs string
want string
}{
{"not a list", `{"raw": "x"}`, "References must be a list of entries."},
{"bad doi", `[{"raw": "x", "doi": "not-a-doi"}]`, "Reference 1: DOI must look like 10.xxxx/suffix."},
{"bad orcid", `[{"raw": "x", "authors": [{"name": "A", "orcid": "0000-0002-1825-0098"}]}]`,
"Reference 1: ORCID must look like 0000-0002-1825-0097."},
{"bad url", `[{"raw": "x", "url": "ftp://example.com"}]`, "Reference 1: URL must be an http(s) address."},
{"bad arxiv", `[{"raw": "x", "arxiv": "not an id"}]`, "Reference 1: arXiv must be the bare identifier"},
{"no citation", `[{"doi": "10.1086/300499"}]`, "Reference 1 needs the citation itself"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := PostFromParams(map[string]string{
"title": "Hello", "slug": "hello", "body": "b", "refs": tc.refs,
}, nil)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}