Initial commit
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
This commit is contained in:
2026-09-29 10:03:32 +02:00
commit f8ed33df83
206 changed files with 44165 additions and 0 deletions
+606
View File
@@ -0,0 +1,606 @@
// 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,
}
}
+386
View File
@@ -0,0 +1,386 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package post
import (
"strings"
"testing"
"time"
"sourcedock.dev/petrbalvin/interpres/v2"
"sourcedock.dev/petrbalvin/volumen/internal/fediverse"
"sourcedock.dev/petrbalvin/volumen/internal/frontmatter"
)
func frontmatterMeta(m map[string]any) *frontmatter.Meta {
meta := frontmatter.NewMeta()
for k, v := range m {
meta.Set(k, v)
}
return meta
}
func frontmatterPost(m map[string]any) *Post {
return New(frontmatterMeta(m), "")
}
func mustParse(t *testing.T, content string) *Post {
t.Helper()
p, err := Parse(content)
if err != nil {
t.Fatalf("post.Parse: %v", err)
}
return p
}
const sample = `+++
title = "Ahoj světe"
slug = "ahoj-svete"
lang = "cs"
author = "Petr"
tags = ["go", "blog"]
date = 2026-08-18
series = "Série"
series_order = 2
cover = "/media/cover.webp"
cover_alt = "alt"
cover_caption = "caption"
fediverse_creator = "@petr@mastodon.social"
aliases = ["old-slug"]
[translations]
en = "hello-world"
+++
# Hlavička
První odstavec s dostatkem slov pro shrnutí, který popisuje obsah
celého článku a nemá nadpis, takže se z něj dá odvodit excerpt.
## Sekce
Další text.
`
func TestParseBasics(t *testing.T) {
p := mustParse(t, sample)
if p.Slug() != "ahoj-svete" {
t.Fatalf("slug = %q", p.Slug())
}
if p.Title() != "Ahoj světe" || p.Lang() != "cs" || p.Author() != "Petr" {
t.Fatalf("title/lang/author wrong")
}
if tags := p.Tags(); len(tags) != 2 || tags[0] != "go" || tags[1] != "blog" {
t.Fatalf("tags = %v", tags)
}
if p.Draft() || p.AllLangs() {
t.Fatal("draft/all_langs should be false")
}
if p.Translations()["en"] != "hello-world" {
t.Fatalf("translations = %v", p.Translations())
}
if p.Aliases()[0] != "old-slug" {
t.Fatalf("aliases = %v", p.Aliases())
}
if p.Cover() != "/media/cover.webp" || p.CoverAlt() != "alt" || p.CoverCaption() != "caption" {
t.Fatal("cover fields wrong")
}
if !fediverse.Valid(p.FediverseCreator()) {
t.Fatal("fediverse creator should be valid")
}
if p.Series() != "Série" {
t.Fatalf("series = %q", p.Series())
}
if order, ok := p.SeriesOrder(); !ok || order != 2 {
t.Fatalf("series_order = %d, %v", order, ok)
}
if !strings.HasPrefix(p.Body, "# Hlavička") {
t.Fatalf("body = %q", p.Body)
}
}
func TestDateStringAndTimestamp(t *testing.T) {
p := mustParse(t, sample)
if p.DateString() != "2026-08-18" {
t.Fatalf("DateString = %q", p.DateString())
}
d, ok := p.PublishedDate()
if !ok || d.Format("2006-01-02") != "2026-08-18" {
t.Fatalf("PublishedDate = %v, %v", d, ok)
}
ts, ok := p.PublishedTimestamp()
if !ok {
t.Fatal("PublishedTimestamp missing")
}
want := time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC).UnixMilli()
if ts != want {
t.Fatalf("timestamp = %d, want %d", ts, want)
}
}
func TestPublishAtAndScheduled(t *testing.T) {
future := mustParse(t, "+++\npublish_at = 2999-01-01\n+++\nx\n")
if !future.Scheduled() {
t.Fatal("future publish_at should be scheduled")
}
past := mustParse(t, "+++\npublish_at = 2000-01-01\n+++\nx\n")
if past.Scheduled() {
t.Fatal("past publish_at should not be scheduled")
}
none := mustParse(t, "+++\ntitle = \"x\"\n+++\nx\n")
if none.Scheduled() {
t.Fatal("missing publish_at should not be scheduled")
}
if _, ok := none.DueAt(); ok {
t.Fatal("missing publish_at returned a date")
}
}
func TestPublishAtStringForms(t *testing.T) {
p := mustParse(t, "+++\npublish_at = \"2026-02-03\"\n+++\nx\n")
d, ok := p.DueAt()
if !ok || d.Format("2006-01-02") != "2026-02-03" {
t.Fatalf("string publish_at = %v, %v", d, ok)
}
}
func TestFlagsStrict(t *testing.T) {
p := mustParse(t, "+++\ndraft = \"true\"\n+++\nx\n")
if p.Draft() {
t.Fatal("string draft flag must not count as draft")
}
p2 := mustParse(t, "+++\ndraft = true\n+++\nx\n")
if !p2.Draft() {
t.Fatal("boolean draft flag should count")
}
}
func TestReadingTime(t *testing.T) {
short := New(nil, "one two three")
if got := short.ReadingTime(); got != 1 {
t.Fatalf("ReadingTime = %d, want 1", got)
}
body := strings.Repeat("word ", 450)
long := New(nil, body)
if got := long.ReadingTime(); got != 3 {
t.Fatalf("ReadingTime = %d, want 3", got)
}
empty := New(nil, "")
if got := empty.ReadingTime(); got != 1 {
t.Fatalf("empty ReadingTime = %d, want 1", got)
}
}
func TestExcerptPrefersMetadata(t *testing.T) {
p := mustParse(t, "+++\nexcerpt = \"Vlastní perex\"\n+++\nbody\n")
if p.Excerpt() != "Vlastní perex" {
t.Fatalf("Excerpt = %q", p.Excerpt())
}
}
func TestDerivedExcerpt(t *testing.T) {
p := New(nil, "# Head\n\nFirst paragraph with **markdown** and <b>html</b>.\n\nSecond.")
ex := p.Excerpt()
if strings.Contains(ex, "*") || strings.Contains(ex, "<") || strings.HasPrefix(ex, "#") {
t.Fatalf("derived excerpt not cleaned: %q", ex)
}
if !strings.Contains(ex, "First paragraph") {
t.Fatalf("derived excerpt wrong: %q", ex)
}
}
func TestDerivedExcerptTruncates(t *testing.T) {
p := New(nil, strings.Repeat("x", 500))
ex := p.Excerpt()
if !strings.HasSuffix(ex, "…") {
t.Fatalf("excerpt not truncated: %q", ex)
}
if len([]rune(ex)) != 201 {
t.Fatalf("excerpt length = %d, want 201", len([]rune(ex)))
}
}
func TestHTMLAndTOCCached(t *testing.T) {
p := mustParse(t, "+++\ntitle = \"x\"\n+++\n# H\n\nText\n")
h1, err := p.HTML()
if err != nil {
t.Fatalf("HTML: %v", err)
}
h2, err := p.HTML()
if err != nil || h1 != h2 {
t.Fatalf("HTML not cached: %q vs %q", h1, h2)
}
toc, err := p.TOC()
if err != nil {
t.Fatalf("TOC: %v", err)
}
if !strings.Contains(toc, `href="#h"`) {
t.Fatalf("toc = %q", toc)
}
}
func TestToFileRoundTrip(t *testing.T) {
p := mustParse(t, sample)
out, err := p.ToFile()
if err != nil {
t.Fatalf("ToFile: %v", err)
}
p2 := mustParse(t, out)
if p2.Title() != p.Title() || p2.Slug() != p.Slug() || p2.Body != p.Body {
t.Fatal("round-trip lost data")
}
}
func TestCivilDateVariants(t *testing.T) {
meta := frontmatterMeta(map[string]any{
"date": interpres.LocalDate{Time: time.Date(2026, 3, 4, 0, 0, 0, 0, time.UTC)},
})
if got := New(meta, "").DateString(); got != "2026-03-04" {
t.Fatalf("LocalDate DateString = %q", got)
}
meta = frontmatterMeta(map[string]any{"date": "2026-03-05"})
if got := New(meta, "").DateString(); got != "2026-03-05" {
t.Fatalf("string DateString = %q", got)
}
meta = frontmatterMeta(map[string]any{"date": 42})
if got := New(meta, "").DateString(); got != "42" {
t.Fatalf("int DateString = %q", got)
}
}
func TestFediverseCreatorValidation(t *testing.T) {
if !fediverse.Valid("@user@host.social") {
t.Fatal("valid handle rejected")
}
for _, bad := range []string{"user@host", "@user@", "@@host", "@us er@host", ""} {
if fediverse.Valid(bad) {
t.Fatalf("invalid handle accepted: %q", bad)
}
}
}
func TestDOIORCIDReservedAndReadable(t *testing.T) {
p := mustParse(t, "+++\nslug = \"paper\"\ndoi = \"10.1000/xyz\"\norcid = \"0000-0002-1825-0097\"\nmethod = \"gravimetric\"\n+++\nx\n")
if p.DOI() != "10.1000/xyz" {
t.Fatalf("DOI = %q", p.DOI())
}
if p.ORCID() != "0000-0002-1825-0097" {
t.Fatalf("ORCID = %q", p.ORCID())
}
// The identifiers are reserved: only the author's own key reaches
// the fields object.
fields := p.CustomFields()
if _, present := fields["doi"]; present {
t.Fatal("doi leaked into custom fields")
}
if _, present := fields["orcid"]; present {
t.Fatal("orcid leaked into custom fields")
}
if fields["method"] != "gravimetric" {
t.Fatalf("custom field lost: %v", fields)
}
}
func TestSeriesOrderCoercions(t *testing.T) {
p := frontmatterPost(map[string]any{"series_order": "7"})
if order, ok := p.SeriesOrder(); !ok || order != 7 {
t.Fatalf("string series_order = %d, %v", order, ok)
}
p = frontmatterPost(map[string]any{"series_order": 3.9})
if order, ok := p.SeriesOrder(); !ok || order != 3 {
t.Fatalf("float series_order = %d, %v", order, ok)
}
p = frontmatterPost(map[string]any{"series_order": "x"})
if _, ok := p.SeriesOrder(); ok {
t.Fatal("garbage series_order accepted")
}
}
func TestUnparseablePublishAtWithholdsThePost(t *testing.T) {
// A typo in publish_at must not publish the post early: it is
// withheld, and validate reports it.
p := mustParse(t, "+++\npublish_at = \"18/09/2026\"\n+++\nx\n")
if p.Published() {
t.Fatal("a post with an unreadable publish_at was published")
}
if !p.Scheduled() {
t.Fatal("a post with an unreadable publish_at was not withheld")
}
if _, ok := p.DueAt(); ok {
t.Fatal("an unreadable publish_at produced a date")
}
}
func TestEmptyPublishAtIsPublished(t *testing.T) {
p := mustParse(t, "+++\npublish_at = \"\"\n+++\nx\n")
if !p.Published() {
t.Fatal("an empty publish_at withheld the post")
}
}
func TestStatusPrecedence(t *testing.T) {
draft := mustParse(t, "+++\ndraft = true\npublish_at = 2999-01-01\n+++\nx\n")
if draft.Status() != StatusDraft {
t.Fatal("a draft outranks a scheduled post")
}
if draft.Published() {
t.Fatal("a draft was published")
}
}
func TestCloneIsIndependent(t *testing.T) {
original := mustParse(t, "+++\ntitle = \"A\"\ntags = [\"go\", \"blog\"]\n[extra]\nkey = \"value\"\n+++\nbody\n")
clone := original.Clone()
clone.Metadata.Set("title", "B")
clone.Metadata.Set("tags", []string{"other"})
if table, ok := clone.Metadata.Get("extra"); ok {
if m, isMap := table.(map[string]any); isMap {
m["key"] = "changed"
}
}
title, _ := original.Metadata.Get("title")
if title != "A" {
t.Fatalf("the clone wrote through to the original: title = %v", title)
}
if got := original.Tags(); len(got) != 2 || got[0] != "go" {
t.Fatalf("the clone shares the tag list: %v", got)
}
if table, ok := original.Metadata.Get("extra"); ok {
if m, isMap := table.(map[string]any); isMap && m["key"] != "value" {
t.Fatalf("the clone shares the nested table: %v", m)
}
}
if clone.Path != original.Path || clone.Body != original.Body {
t.Fatal("the clone lost the body or the path")
}
}
func TestStoredExcerptIsNotDerived(t *testing.T) {
withExcerpt := mustParse(t, "+++\nexcerpt = \"Hand written\"\n+++\nFirst paragraph.\n")
if withExcerpt.StoredExcerpt() != "Hand written" {
t.Fatalf("StoredExcerpt = %q", withExcerpt.StoredExcerpt())
}
derived := mustParse(t, "+++\ntitle = \"x\"\n+++\nFirst paragraph.\n")
if derived.StoredExcerpt() != "" {
t.Fatalf("StoredExcerpt = %q, want empty before the editor fills it", derived.StoredExcerpt())
}
if derived.Excerpt() == "" {
t.Fatal("Excerpt should still derive from the body")
}
}
func TestPlainTextAndTruncate(t *testing.T) {
if got := PlainText("<p>Hello <b>world</b></p>", 100); got != "Hello world" {
t.Fatalf("PlainText = %q", got)
}
if got := PlainText("<p>one two</p>", 100); got != "one two" {
t.Fatalf("PlainText did not collapse whitespace: %q", got)
}
if got := Truncate("abcdefghij", 4); got != "abcd…" {
t.Fatalf("Truncate = %q", got)
}
if got := Truncate("abc", 4); got != "abc" {
t.Fatalf("Truncate changed a short value: %q", got)
}
}
+149
View File
@@ -0,0 +1,149 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
package post
import (
"strings"
"testing"
"sourcedock.dev/petrbalvin/volumen/internal/biblio"
)
// refsSample is a full post file with a [[refs]] array of tables, run
// through the real frontmatter parser so the metadata shape the store
// actually delivers is what is under test.
const refsSample = `+++
title = "Citující práce"
slug = "cite"
lang = "cs"
date = 2026-08-18
[[refs]]
title = "Observational evidence from supernovae"
authors = ["Riess, A. G. a kol."]
venue = "The Astronomical Journal"
year = 1998
doi = "doi:10.1103/PhysRevD.59.103502"
[[refs]]
raw = "Goldhaber, G. a kol., arXiv:astro-ph/0408076"
+++
První tvrzení [1], druhé tvrzení [2] a inline kód ` + "`list[2] = 3`" + `.
` + "```" + `go
pole[1] se nemění
` + "```" + `
` + biblio.Marker + `
`
func TestRefsParseAndWeave(t *testing.T) {
p := mustParse(t, refsSample)
refs := p.Refs()
if len(refs) != 2 {
t.Fatalf("refs = %d, want 2", len(refs))
}
if refs[0].Title == "" || refs[0].DOI != "10.1103/PhysRevD.59.103502" {
t.Fatalf("structured ref = %+v", refs[0])
}
if refs[1].ArXiv != "astro-ph/0408076" {
t.Fatalf("raw ref not enriched: %+v", refs[1])
}
// refs is reserved: it never leaks into the custom fields object.
if fields := p.CustomFields(); fields != nil {
if _, ok := fields["refs"]; ok {
t.Fatal("refs leaked into custom fields")
}
}
htmlOut, err := p.HTML()
if err != nil {
t.Fatalf("HTML: %v", err)
}
// Inline citations become anchors, the list is spliced at the marker,
// identifiers in entries become resolver links, and bracketed
// numbers inside code stay exactly as written.
for _, want := range []string{
`<a class="ref-cite" href="#ref-1">[1]</a>`,
`<section class="refs" id="references">`,
`<li id="ref-2">`,
`href="https://doi.org/10.1103/PhysRevD.59.103502"`,
"list[2] = 3",
"pole[1] se nemění",
} {
if !strings.Contains(htmlOut, want) {
t.Fatalf("html missing %q:\n%s", want, htmlOut)
}
}
if strings.Contains(htmlOut, biblio.Marker) {
t.Fatalf("marker not replaced:\n%s", htmlOut)
}
// A post without refs renders unchanged and carries none.
bare := mustParse(t, "+++\ntitle = \"T\"\nslug = \"t\"\n+++\ntext [1]\n")
if len(bare.Refs()) != 0 {
t.Fatal("bare post has refs")
}
if html, _ := bare.HTML(); strings.Contains(html, "ref-cite") {
t.Fatal("bare post got citation links")
}
}
// A save from the editor must not drop the bibliography: the form never
// carries refs, and the merge keeps untouched keys.
func TestToFileSyncPreservesRefs(t *testing.T) {
p := mustParse(t, refsSample)
out, err := p.ToFile()
if err != nil {
t.Fatalf("ToFile: %v", err)
}
if !strings.Contains(out, "[[refs]]") || !strings.Contains(out, "10.1103/PhysRevD.59.103502") {
t.Fatalf("refs lost on serialise:\n%s", out)
}
}
func TestInternalCrossLinksAtRender(t *testing.T) {
// The cited work is published under another slug in the instance:
// the rendered list links to that post, and the entry's resolver
// link disappears from the page.
p := mustParse(t, refsSample)
p.SetLinkIndex(map[string]string{"10.1103/physrevd.59.103502": "riess-1998"})
htmlOut, err := p.HTML()
if err != nil {
t.Fatalf("HTML: %v", err)
}
if !strings.Contains(htmlOut, `href="/api/volumen/posts/riess-1998"`) {
t.Fatalf("internal link missing:\n%s", htmlOut)
}
if strings.Contains(htmlOut, `href="https://doi.org/10.1103/PhysRevD.59.103502"`) {
t.Fatal("external resolver survived")
}
// The arXiv entry is not published here: it keeps its resolver.
if !strings.Contains(htmlOut, `href="https://arxiv.org/abs/astro-ph/0408076"`) {
t.Fatalf("unmatched entry lost its link:\n%s", htmlOut)
}
// A post citing its own DOI must not link to itself.
self := mustParse(t, strings.Replace(refsSample, `slug = "cite"`, `slug = "cite"
doi = "10.1103/PhysRevD.59.103502"`, 1))
self.SetLinkIndex(map[string]string{"10.1103/physrevd.59.103502": "cite"})
htmlOut, err = self.HTML()
if err != nil {
t.Fatalf("HTML: %v", err)
}
if strings.Contains(htmlOut, `href="/api/volumen/posts/cite"`) {
t.Fatal("self-link was built")
}
if !strings.Contains(htmlOut, `href="https://doi.org/10.1103/PhysRevD.59.103502"`) {
t.Fatalf("self cite lost its resolver link:\n%s", htmlOut)
}
// RefsLinked exposes the resolution to the API layer.
linked := p.RefsLinked()
if linked[0].Internal != "/api/volumen/posts/riess-1998" {
t.Fatalf("RefsLinked[0] = %+v", linked[0])
}
if linked[1].Internal != "" {
t.Fatalf("RefsLinked[1] should be external: %+v", linked[1])
}
}