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
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:
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user