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