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