// Copyright (c) 2026 Petr BalvĂ­n (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, "/") }