Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
135 lines
3.3 KiB
Go
135 lines
3.3 KiB
Go
package admin
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type Exporter struct {
|
|
postsDir string
|
|
}
|
|
|
|
type postFrontmatter struct {
|
|
ID string `yaml:"id"`
|
|
Slug string `yaml:"slug"`
|
|
Title string `yaml:"title"`
|
|
Summary string `yaml:"summary"`
|
|
Status string `yaml:"status"`
|
|
Tags []string `yaml:"tags"`
|
|
Cover string `yaml:"cover"`
|
|
Version int `yaml:"version"`
|
|
SlugSource string `yaml:"slug_source"`
|
|
SlugLocked bool `yaml:"slug_locked"`
|
|
PublishedAt string `yaml:"published_at"`
|
|
CreatedAt string `yaml:"created_at"`
|
|
UpdatedAt string `yaml:"updated_at"`
|
|
}
|
|
|
|
func NewExporter(postsDir string) *Exporter {
|
|
return &Exporter{postsDir: postsDir}
|
|
}
|
|
|
|
func (e *Exporter) ExportPublishedPosts(ctx context.Context, posts []Post) error {
|
|
if err := os.MkdirAll(e.postsDir, 0o755); err != nil {
|
|
return fmt.Errorf("create posts dir: %w", err)
|
|
}
|
|
|
|
publishedFiles := make(map[string]bool, len(posts))
|
|
for _, post := range posts {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
publishedFiles[post.Slug+".md"] = true
|
|
if err := e.writePost(post); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := e.removeStalePosts(publishedFiles); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Exporter) removeStalePosts(publishedFiles map[string]bool) error {
|
|
entries, err := os.ReadDir(e.postsDir)
|
|
if err != nil {
|
|
return fmt.Errorf("read posts dir: %w", err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
|
continue
|
|
}
|
|
if publishedFiles[entry.Name()] {
|
|
continue
|
|
}
|
|
if err := os.Remove(filepath.Join(e.postsDir, entry.Name())); err != nil {
|
|
return fmt.Errorf("remove stale post %s: %w", entry.Name(), err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Exporter) writePost(post Post) error {
|
|
path := filepath.Join(e.postsDir, post.Slug+".md")
|
|
frontmatter := postFrontmatter{
|
|
ID: post.ID,
|
|
Slug: post.Slug,
|
|
Title: post.Title,
|
|
Summary: post.Summary,
|
|
Status: string(post.Status),
|
|
Tags: post.Tags,
|
|
Cover: post.Cover,
|
|
Version: post.Version,
|
|
SlugSource: post.SlugSource,
|
|
SlugLocked: post.SlugLocked,
|
|
PublishedAt: formatFrontmatterTime(post.PublishedAt),
|
|
CreatedAt: formatFrontmatterTime(&post.CreatedAt),
|
|
UpdatedAt: formatFrontmatterTime(&post.UpdatedAt),
|
|
}
|
|
|
|
var meta bytes.Buffer
|
|
encoder := yaml.NewEncoder(&meta)
|
|
encoder.SetIndent(2)
|
|
if err := encoder.Encode(frontmatter); err != nil {
|
|
return fmt.Errorf("encode frontmatter: %w", err)
|
|
}
|
|
if err := encoder.Close(); err != nil {
|
|
return fmt.Errorf("close frontmatter encoder: %w", err)
|
|
}
|
|
|
|
var output bytes.Buffer
|
|
output.WriteString("---\n")
|
|
output.Write(meta.Bytes())
|
|
output.WriteString("---\n\n")
|
|
output.WriteString(strings.TrimLeft(post.BodyMarkdown, "\n"))
|
|
if !strings.HasSuffix(output.String(), "\n") {
|
|
output.WriteByte('\n')
|
|
}
|
|
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
|
return fmt.Errorf("write post %s: %w", post.Slug, err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return fmt.Errorf("replace post %s: %w", post.Slug, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func formatFrontmatterTime(value *time.Time) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return value.Format(time.RFC3339)
|
|
}
|