336 lines
9.6 KiB
Go
336 lines
9.6 KiB
Go
// 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, "/") }
|