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
607 lines
17 KiB
Go
607 lines
17 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
|
|
// Package post is the post domain object: TOML frontmatter
|
|
// metadata plus a Markdown body, with derived properties and the
|
|
// public API payload shapes.
|
|
package post
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"sourcedock.dev/petrbalvin/interpres/v2"
|
|
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
|
|
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
|
|
"sourcedock.dev/petrbalvin/volumen/internal/identifiers"
|
|
"sourcedock.dev/petrbalvin/volumen/internal/markdown"
|
|
)
|
|
|
|
var (
|
|
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
|
// Markdown's own emphasis and quote markers, stripped from a derived
|
|
// excerpt. An underscore inside a word is literal in Markdown, so it
|
|
// is stripped only where it could have marked emphasis.
|
|
mdCharsRe = regexp.MustCompile("[*`>#]|\\b_\\b")
|
|
)
|
|
|
|
// Status is the publication state of a post.
|
|
type Status int
|
|
|
|
const (
|
|
// StatusPublished marks a post the public API serves.
|
|
StatusPublished Status = iota
|
|
// StatusDraft marks a post with draft = true.
|
|
StatusDraft
|
|
// StatusScheduled marks a post whose publish_at lies in the future,
|
|
// or which carries a publish_at that cannot be parsed.
|
|
StatusScheduled
|
|
)
|
|
|
|
// Post is a single post with metadata and a Markdown body.
|
|
type Post struct {
|
|
Metadata *frontmatter.Meta
|
|
Body string
|
|
Path string
|
|
|
|
// fileSlug and fileLang hold the values the store derived from where
|
|
// the file lives. They are a read-time fallback, never metadata, so a
|
|
// save does not materialise them into the frontmatter.
|
|
fileSlug string
|
|
fileLang string
|
|
|
|
renderOnce sync.Once
|
|
htmlOut string
|
|
tocOut string
|
|
renderErr error
|
|
|
|
dateOnce sync.Once
|
|
dateOut string
|
|
|
|
// linkIndex maps a normalised DOI to the slug of the post published
|
|
// under it in this instance. The store sets it when it (re)loads the
|
|
// cache; a post outside the store has none and links only
|
|
// externally.
|
|
linkIndex map[string]string
|
|
}
|
|
|
|
// New builds a post from metadata and body.
|
|
func New(metadata *frontmatter.Meta, body string) *Post {
|
|
if metadata == nil {
|
|
metadata = frontmatter.NewMeta()
|
|
}
|
|
return &Post{Metadata: metadata, Body: body}
|
|
}
|
|
|
|
// Parse builds a post from a full file string (frontmatter + body).
|
|
// Invalid frontmatter TOML is an error.
|
|
func Parse(content string) (*Post, error) {
|
|
meta, body, err := frontmatter.Parse(content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return New(meta, body), nil
|
|
}
|
|
|
|
func (p *Post) str(key string) string {
|
|
v, ok := p.Metadata.Get(key)
|
|
if !ok || v == nil {
|
|
return ""
|
|
}
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
|
|
// Slug returns the frontmatter slug, falling back to the value the store
|
|
// derived from the file name. The fallback is not written back: a post
|
|
// whose slug comes from its file name keeps a frontmatter without one.
|
|
func (p *Post) Slug() string {
|
|
if slug := p.str("slug"); slug != "" {
|
|
return slug
|
|
}
|
|
return p.fileSlug
|
|
}
|
|
|
|
// Title returns the post title, or the empty string.
|
|
func (p *Post) Title() string { return p.str("title") }
|
|
|
|
// Lang returns the post language code, falling back to the directory the
|
|
// store found the file in. Like Slug, the fallback is not written back.
|
|
func (p *Post) Lang() string {
|
|
if lang := p.str("lang"); lang != "" {
|
|
return lang
|
|
}
|
|
return p.fileLang
|
|
}
|
|
|
|
// Author returns the post author, or the empty string.
|
|
func (p *Post) Author() string { return p.str("author") }
|
|
|
|
// Tags returns the post tags as strings.
|
|
func (p *Post) Tags() []string {
|
|
raw, ok := p.Metadata.Get("tags")
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return stringList(raw)
|
|
}
|
|
|
|
// stringList normalises the TOML/array shapes metadata values arrive in
|
|
// ([]any from parsing, []string from form data).
|
|
func stringList(raw any) []string {
|
|
switch list := raw.(type) {
|
|
case []string:
|
|
return slices.Clone(list)
|
|
case []any:
|
|
out := make([]string, 0, len(list))
|
|
for _, t := range list {
|
|
out = append(out, fmt.Sprintf("%v", t))
|
|
}
|
|
return out
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReservedMetadata are the frontmatter keys the engine consumes itself.
|
|
// Any other key is the author's own: it survives a save untouched and
|
|
// passes through to the API's fields object.
|
|
var ReservedMetadata = map[string]bool{
|
|
"title": true, "slug": true, "lang": true, "author": true,
|
|
"fediverse_creator": true, "doi": true, "orcid": true,
|
|
"date": true, "publish_at": true,
|
|
"tags": true, "excerpt": true, "cover": true, "cover_alt": true,
|
|
"cover_caption": true, "series": true, "series_order": true,
|
|
"draft": true, "all_langs": true,
|
|
"translations": true, "aliases": true, "refs": true,
|
|
}
|
|
|
|
// CustomFields returns the frontmatter keys outside ReservedMetadata,
|
|
// the author's own, with values shaped for the JSON API: TOML date-time
|
|
// wrappers become their canonical strings, so no internal type leaks
|
|
// into the payload. The result is nil when every key is a known one.
|
|
func (p *Post) CustomFields() map[string]any {
|
|
var out map[string]any
|
|
for _, key := range p.Metadata.Keys() {
|
|
if ReservedMetadata[key] {
|
|
continue
|
|
}
|
|
value, ok := p.Metadata.Get(key)
|
|
if !ok || value == nil {
|
|
continue
|
|
}
|
|
if out == nil {
|
|
out = map[string]any{}
|
|
}
|
|
out[key] = customFieldValue(value)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// customFieldValue normalises one metadata value for the fields object.
|
|
func customFieldValue(v any) any {
|
|
switch t := v.(type) {
|
|
case interpres.LocalDate:
|
|
return t.String()
|
|
case interpres.LocalDateTime:
|
|
return t.String()
|
|
case interpres.LocalTime:
|
|
return t.String()
|
|
case interpres.OffsetDateTime:
|
|
return t.String()
|
|
case map[string]any:
|
|
out := make(map[string]any, len(t))
|
|
for key, item := range t {
|
|
out[key] = customFieldValue(item)
|
|
}
|
|
return out
|
|
case []any:
|
|
out := make([]any, len(t))
|
|
for i, item := range t {
|
|
out[i] = customFieldValue(item)
|
|
}
|
|
return out
|
|
}
|
|
return v
|
|
}
|
|
|
|
// Draft reports whether the draft flag is strictly true.
|
|
func (p *Post) Draft() bool {
|
|
v, ok := p.Metadata.Get("draft")
|
|
return ok && v == true
|
|
}
|
|
|
|
// AllLangs reports whether the all_langs flag is strictly true.
|
|
func (p *Post) AllLangs() bool {
|
|
v, ok := p.Metadata.Get("all_langs")
|
|
return ok && v == true
|
|
}
|
|
|
|
// Translations returns the lang-to-slug translation map.
|
|
func (p *Post) Translations() map[string]string {
|
|
raw, ok := p.Metadata.Get("translations")
|
|
if !ok {
|
|
return nil
|
|
}
|
|
table, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(table))
|
|
for k, v := range table {
|
|
out[k] = fmt.Sprintf("%v", v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Aliases returns old slugs that redirect to this post.
|
|
func (p *Post) Aliases() []string {
|
|
raw, ok := p.Metadata.Get("aliases")
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return stringList(raw)
|
|
}
|
|
|
|
// Refs returns the structured bibliography the post cites. The engine
|
|
// renders it into the HTML (see internal/biblio) and the API serves it
|
|
// as data; parsing is lenient, and a malformed entry simply keeps its
|
|
// verbatim raw text.
|
|
func (p *Post) Refs() []biblio.Entry {
|
|
raw, ok := p.Metadata.Get("refs")
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var tables []map[string]any
|
|
switch list := raw.(type) {
|
|
case []any:
|
|
for _, item := range list {
|
|
if table, ok := item.(map[string]any); ok {
|
|
tables = append(tables, table)
|
|
}
|
|
}
|
|
case []map[string]any:
|
|
tables = list
|
|
default:
|
|
return nil
|
|
}
|
|
return biblio.Parse(tables)
|
|
}
|
|
|
|
// SetLinkIndex gives the post the instance's DOI-to-slug map, so a
|
|
// reference citing a work published here can link to its post instead
|
|
// of leaving the instance. The store calls this for every cached post;
|
|
// the map is treated as read-only afterwards.
|
|
func (p *Post) SetLinkIndex(index map[string]string) {
|
|
p.linkIndex = index
|
|
}
|
|
|
|
// LinkFor returns the API URL of the post published under the given DOI
|
|
// in this instance, or "" when the DOI is unknown here, malformed, or
|
|
// belongs to the citing post itself.
|
|
func (p *Post) LinkFor(doi string) string {
|
|
if p.linkIndex == nil || doi == "" {
|
|
return ""
|
|
}
|
|
normalised := strings.ToLower(identifiers.NormalizeDOI(doi))
|
|
slug, ok := p.linkIndex[normalised]
|
|
if !ok || slug == "" || slug == p.Slug() {
|
|
return ""
|
|
}
|
|
return "/api/volumen/posts/" + slug
|
|
}
|
|
|
|
// RefsLinked returns the parsed reference list with each entry's
|
|
// Internal link resolved against the instance, for rendering and for
|
|
// the API payloads.
|
|
func (p *Post) RefsLinked() []biblio.Entry {
|
|
refs := p.Refs()
|
|
for i := range refs {
|
|
refs[i].Internal = p.LinkFor(refs[i].DOI)
|
|
}
|
|
return refs
|
|
}
|
|
|
|
// Cover returns the cover image URL, or the empty string.
|
|
func (p *Post) Cover() string { return p.str("cover") }
|
|
|
|
// CoverAlt returns the cover image alt text.
|
|
func (p *Post) CoverAlt() string { return p.str("cover_alt") }
|
|
|
|
// CoverCaption returns the cover image caption.
|
|
func (p *Post) CoverCaption() string { return p.str("cover_caption") }
|
|
|
|
// FediverseCreator returns the stripped fediverse handle, or empty.
|
|
func (p *Post) FediverseCreator() string {
|
|
v, ok := p.Metadata.Get("fediverse_creator")
|
|
if !ok || v == nil {
|
|
return ""
|
|
}
|
|
text := strings.TrimSpace(fmt.Sprintf("%v", v))
|
|
return text
|
|
}
|
|
|
|
// DOI returns the raw Digital Object Identifier frontmatter value, or
|
|
// empty. Validation and normalisation live in internal/identifiers.
|
|
func (p *Post) DOI() string { return p.str("doi") }
|
|
|
|
// ORCID returns the raw ORCID iD frontmatter value, or empty.
|
|
func (p *Post) ORCID() string { return p.str("orcid") }
|
|
|
|
// DueAt returns the scheduled publication date when the frontmatter
|
|
// carries a parseable publish_at.
|
|
func (p *Post) DueAt() (time.Time, bool) {
|
|
v, ok := p.Metadata.Get("publish_at")
|
|
if !ok || v == nil {
|
|
return time.Time{}, false
|
|
}
|
|
if text, isString := v.(string); isString && strings.TrimSpace(text) == "" {
|
|
return time.Time{}, false
|
|
}
|
|
return civilDate(v)
|
|
}
|
|
|
|
// Status returns the publication state. A draft outranks everything
|
|
// else, and a publish_at that cannot be parsed withholds the post
|
|
// rather than publishing it early, because publishing on a typo is the
|
|
// failure that cannot be undone.
|
|
func (p *Post) Status() Status {
|
|
if p.Draft() {
|
|
return StatusDraft
|
|
}
|
|
due, ok := p.DueAt()
|
|
if !ok {
|
|
if v, present := p.Metadata.Get("publish_at"); present && v != nil {
|
|
if text, isString := v.(string); isString && strings.TrimSpace(text) == "" {
|
|
return StatusPublished
|
|
}
|
|
return StatusScheduled
|
|
}
|
|
return StatusPublished
|
|
}
|
|
if due.After(TodayUTC()) {
|
|
return StatusScheduled
|
|
}
|
|
return StatusPublished
|
|
}
|
|
|
|
// Published reports whether the post is visible on the public API.
|
|
func (p *Post) Published() bool { return p.Status() == StatusPublished }
|
|
|
|
// Scheduled reports whether the post is withheld until a later date.
|
|
func (p *Post) Scheduled() bool { return p.Status() == StatusScheduled }
|
|
|
|
// SetFileLocation records the slug and the language the store derived
|
|
// from where the file lives, used when the frontmatter carries neither.
|
|
func (p *Post) SetFileLocation(slug, lang string) {
|
|
p.fileSlug, p.fileLang = slug, lang
|
|
}
|
|
|
|
// TodayUTC returns the current civil date in UTC. Civil dates compare in
|
|
// one zone so that a deployment's locale cannot move a publication
|
|
// boundary.
|
|
func TodayUTC() time.Time {
|
|
y, m, d := time.Now().UTC().Date()
|
|
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
// Series returns the series name, or the empty string.
|
|
func (p *Post) Series() string { return p.str("series") }
|
|
|
|
// SeriesOrder returns the position within the series, if any.
|
|
func (p *Post) SeriesOrder() (int, bool) {
|
|
v, ok := p.Metadata.Get("series_order")
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
switch n := v.(type) {
|
|
case int64:
|
|
return int(n), true
|
|
case int:
|
|
return n, true
|
|
case float64:
|
|
return int(n), true
|
|
case string:
|
|
if i, err := strconv.Atoi(strings.TrimSpace(n)); err == nil {
|
|
return i, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// ReadingTime returns the estimated reading time in minutes.
|
|
func (p *Post) ReadingTime() int {
|
|
words := len(strings.Fields(p.Body))
|
|
if words == 0 {
|
|
return 1
|
|
}
|
|
return max(1, (words+199)/200)
|
|
}
|
|
|
|
// DateString returns the frontmatter date as YYYY-MM-DD, or the raw
|
|
// string value.
|
|
func (p *Post) DateString() string {
|
|
// Formatting a date is one of the most expensive things a list
|
|
// request does, and it is asked for once per post per response, so
|
|
// the result is memoised the way the rendered HTML is. A post that
|
|
// is mutated is a clone, which carries its own guess.
|
|
p.dateOnce.Do(func() {
|
|
v, ok := p.Metadata.Get("date")
|
|
if !ok || v == nil {
|
|
p.dateOut = ""
|
|
return
|
|
}
|
|
switch d := v.(type) {
|
|
case interpres.LocalDate:
|
|
p.dateOut = d.Format("2006-01-02")
|
|
case interpres.LocalDateTime:
|
|
p.dateOut = d.Format("2006-01-02")
|
|
case time.Time:
|
|
p.dateOut = d.Format("2006-01-02")
|
|
case interpres.OffsetDateTime:
|
|
p.dateOut = d.Format("2006-01-02")
|
|
default:
|
|
p.dateOut = fmt.Sprintf("%v", v)
|
|
}
|
|
})
|
|
return p.dateOut
|
|
}
|
|
|
|
// PublishedDate returns the post date as a UTC-midnight civil date.
|
|
func (p *Post) PublishedDate() (time.Time, bool) {
|
|
v, ok := p.Metadata.Get("date")
|
|
if !ok {
|
|
return time.Time{}, false
|
|
}
|
|
return civilDate(v)
|
|
}
|
|
|
|
// PublishedTimestamp returns the post date as a UTC unix timestamp in
|
|
// milliseconds, for client-side relative-time rendering.
|
|
func (p *Post) PublishedTimestamp() (int64, bool) {
|
|
d, ok := p.PublishedDate()
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return d.UnixMilli(), true
|
|
}
|
|
|
|
// civilDate normalises the frontmatter date-ish values to a UTC
|
|
// midnight time.Time.
|
|
func civilDate(v any) (time.Time, bool) {
|
|
switch d := v.(type) {
|
|
case interpres.LocalDate:
|
|
return midnight(d.Time), true
|
|
case interpres.LocalDateTime:
|
|
return midnight(d.Time), true
|
|
case time.Time:
|
|
return midnight(d), true
|
|
case interpres.OffsetDateTime:
|
|
return midnight(d.Time), true
|
|
case string:
|
|
s := strings.TrimSpace(d)
|
|
if t, err := time.Parse("2006-01-02", s); err == nil {
|
|
return midnight(t), true
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
|
return midnight(t), true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func midnight(t time.Time) time.Time {
|
|
y, m, d := t.Date()
|
|
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
|
|
// StoredExcerpt returns the excerpt exactly as the frontmatter holds it,
|
|
// or the empty string. An editor shows this, so a save never writes a
|
|
// derived value the author did not type.
|
|
func (p *Post) StoredExcerpt() string {
|
|
v, ok := p.Metadata.Get("excerpt")
|
|
if !ok || v == nil {
|
|
return ""
|
|
}
|
|
text := fmt.Sprintf("%v", v)
|
|
if strings.TrimSpace(text) == "" {
|
|
return ""
|
|
}
|
|
return text
|
|
}
|
|
|
|
// Excerpt returns the frontmatter excerpt, or one derived from the body.
|
|
func (p *Post) Excerpt() string {
|
|
if v, ok := p.Metadata.Get("excerpt"); ok && v != nil {
|
|
text := fmt.Sprintf("%v", v)
|
|
if strings.TrimSpace(text) != "" {
|
|
return text
|
|
}
|
|
}
|
|
return p.derivedExcerpt(200)
|
|
}
|
|
|
|
// HTML returns the sanitised rendered body, cached per post.
|
|
func (p *Post) HTML() (string, error) {
|
|
p.render()
|
|
return p.htmlOut, p.renderErr
|
|
}
|
|
|
|
// TOC returns the sanitised table-of-contents HTML, cached per post.
|
|
func (p *Post) TOC() (string, error) {
|
|
p.render()
|
|
return p.tocOut, p.renderErr
|
|
}
|
|
|
|
func (p *Post) render() {
|
|
p.renderOnce.Do(func() {
|
|
p.htmlOut, p.tocOut, p.renderErr = markdown.RenderWithTOC(p.Body)
|
|
if p.renderErr != nil {
|
|
return
|
|
}
|
|
if refs := p.RefsLinked(); len(refs) > 0 {
|
|
// The inline [n] markers gain anchors first, while the
|
|
// marker paragraph is still inert text; the list itself is
|
|
// then spliced in, unrewritten by the citation pass.
|
|
p.htmlOut = biblio.LinkCitations(p.htmlOut, refs)
|
|
p.htmlOut = biblio.Place(p.htmlOut, refs)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ToFile serialises the post back into a frontmatter file string.
|
|
func (p *Post) ToFile() (string, error) {
|
|
return frontmatter.Dump(p.Metadata, p.Body)
|
|
}
|
|
|
|
func (p *Post) derivedExcerpt(limit int) string {
|
|
paragraphs := strings.SplitSeq(p.Body, "\n\n")
|
|
for para := range paragraphs {
|
|
stripped := strings.TrimSpace(para)
|
|
if stripped == "" || strings.HasPrefix(stripped, "#") {
|
|
continue
|
|
}
|
|
cleaned := htmlTagRe.ReplaceAllString(stripped, "")
|
|
cleaned = strings.TrimSpace(mdCharsRe.ReplaceAllString(cleaned, ""))
|
|
return Truncate(cleaned, limit)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// PlainText strips HTML tags and collapses whitespace in already
|
|
// rendered HTML, then truncates the result to limit runes with an
|
|
// ellipsis. The excerpt derived from the Markdown body and the summary
|
|
// derived from the rendered HTML go through one implementation, so a
|
|
// client sees the same shape from both.
|
|
func PlainText(htmlOut string, limit int) string {
|
|
plain := htmlTagRe.ReplaceAllString(htmlOut, "")
|
|
return Truncate(strings.Join(strings.Fields(plain), " "), limit)
|
|
}
|
|
|
|
// Truncate cuts value to limit runes, appending an ellipsis when
|
|
// anything was dropped.
|
|
func Truncate(value string, limit int) string {
|
|
runes := []rune(value)
|
|
if len(runes) <= limit {
|
|
return value
|
|
}
|
|
return strings.TrimRight(string(runes[:limit]), " ") + "…"
|
|
}
|
|
|
|
// Clone returns a copy of the post: metadata (keys, order, values and
|
|
// the comments they carry), body and path. Callers that mutate a post
|
|
// obtained from the store must clone it first, because store-cached
|
|
// posts are shared between requests and the in-app scheduler.
|
|
func (p *Post) Clone() *Post {
|
|
return &Post{
|
|
Metadata: p.Metadata.Clone(), Body: p.Body, Path: p.Path,
|
|
fileSlug: p.fileSlug, fileLang: p.fileLang, linkIndex: p.linkIndex,
|
|
}
|
|
}
|