feat: add admin publishing workflow and yar theme
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.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
135
backend/internal/admin/exporter.go
Normal file
135
backend/internal/admin/exporter.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue