Files
volumen/internal/post/post.go
T

607 lines
17 KiB
Go
Raw 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 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,
}
}