Files

336 lines
9.6 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// 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, "/") }