// Copyright (c) 2026 Petr Balvín (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, } }