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) }