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