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
239
backend/internal/admin/markdown_import.go
Normal file
239
backend/internal/admin/markdown_import.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type MarkdownImportResult struct {
|
||||
Imported int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
type markdownFrontmatter struct {
|
||||
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 (s *Store) ImportMarkdownPosts(ctx context.Context, postsDir string) (MarkdownImportResult, error) {
|
||||
entries, err := os.ReadDir(postsDir)
|
||||
if err != nil {
|
||||
return MarkdownImportResult{}, fmt.Errorf("read posts dir: %w", err)
|
||||
}
|
||||
|
||||
var result MarkdownImportResult
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := readMarkdownPost(filepath.Join(postsDir, entry.Name()))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if post.Title == "" || post.Slug == "" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := s.upsertImportedPost(ctx, post); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readMarkdownPost(path string) (Post, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("read markdown post %s: %w", path, err)
|
||||
}
|
||||
|
||||
frontmatterData, body, err := splitMarkdownFrontmatter(data)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("parse markdown post %s: %w", path, err)
|
||||
}
|
||||
|
||||
var meta markdownFrontmatter
|
||||
if err := yaml.Unmarshal(frontmatterData, &meta); err != nil {
|
||||
return Post{}, fmt.Errorf("parse frontmatter %s: %w", path, err)
|
||||
}
|
||||
|
||||
slug := strings.TrimSpace(meta.Slug)
|
||||
if slug == "" {
|
||||
slug = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
}
|
||||
status := PostStatus(strings.TrimSpace(meta.Status))
|
||||
if !ValidPostStatus(status) || status == PostStatusDeleted {
|
||||
status = PostStatusDraft
|
||||
}
|
||||
version := meta.Version
|
||||
if version < 1 {
|
||||
version = 1
|
||||
}
|
||||
slugSource := strings.TrimSpace(meta.SlugSource)
|
||||
if slugSource == "" {
|
||||
slugSource = "manual"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
createdAt := parseFrontmatterTime(meta.CreatedAt, now)
|
||||
updatedAt := parseFrontmatterTime(meta.UpdatedAt, createdAt)
|
||||
var publishedAt *time.Time
|
||||
if parsed, ok := parseOptionalFrontmatterTime(meta.PublishedAt); ok {
|
||||
publishedAt = &parsed
|
||||
}
|
||||
|
||||
return Post{
|
||||
Slug: slug,
|
||||
Title: strings.TrimSpace(meta.Title),
|
||||
Summary: meta.Summary,
|
||||
BodyMarkdown: strings.TrimLeft(string(body), "\n"),
|
||||
Status: status,
|
||||
Tags: normalizeTagNames(meta.Tags),
|
||||
Cover: meta.Cover,
|
||||
Version: version,
|
||||
SlugSource: slugSource,
|
||||
SlugLocked: meta.SlugLocked,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) upsertImportedPost(ctx context.Context, post Post) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin markdown import: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
imported, err := scanPost(tx.QueryRow(ctx, `
|
||||
INSERT INTO posts (
|
||||
slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (slug)
|
||||
DO UPDATE SET
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
body_markdown = excluded.body_markdown,
|
||||
status = excluded.status,
|
||||
cover = excluded.cover,
|
||||
version = GREATEST(posts.version, excluded.version),
|
||||
slug_source = excluded.slug_source,
|
||||
slug_locked = excluded.slug_locked,
|
||||
published_at = excluded.published_at,
|
||||
updated_at = excluded.updated_at,
|
||||
deleted_at = NULL
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
||||
post.Slug,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.BodyMarkdown,
|
||||
post.Status,
|
||||
post.Cover,
|
||||
post.Version,
|
||||
post.SlugSource,
|
||||
post.SlugLocked,
|
||||
post.PublishedAt,
|
||||
post.CreatedAt,
|
||||
post.UpdatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert markdown post %s: %w", post.Slug, err)
|
||||
}
|
||||
if err := replacePostTags(ctx, tx, imported.ID, post.Tags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (post_id, version) DO NOTHING`,
|
||||
imported.ID,
|
||||
imported.Version,
|
||||
imported.Title,
|
||||
imported.Summary,
|
||||
imported.BodyMarkdown,
|
||||
imported.Status,
|
||||
VersionReasonImport,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert markdown import version %s: %w", post.Slug, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit markdown import: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitMarkdownFrontmatter(data []byte) ([]byte, []byte, error) {
|
||||
if !bytes.HasPrefix(data, []byte("---\n")) {
|
||||
return nil, nil, errors.New("missing frontmatter opening marker")
|
||||
}
|
||||
|
||||
rest := data[len("---\n"):]
|
||||
idx := bytes.Index(rest, []byte("\n---"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("missing frontmatter closing marker")
|
||||
}
|
||||
|
||||
frontmatter := rest[:idx]
|
||||
body := rest[idx+len("\n---"):]
|
||||
if bytes.HasPrefix(body, []byte("\r\n")) {
|
||||
body = body[2:]
|
||||
} else if bytes.HasPrefix(body, []byte("\n")) {
|
||||
body = body[1:]
|
||||
}
|
||||
return frontmatter, body, nil
|
||||
}
|
||||
|
||||
func parseFrontmatterTime(value string, fallback time.Time) time.Time {
|
||||
if parsed, ok := parseOptionalFrontmatterTime(value); ok {
|
||||
return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseOptionalFrontmatterTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.999999999Z07",
|
||||
"2006-01-02 15:04:05.999999Z07",
|
||||
"2006-01-02 15:04:05Z07",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue