Initialize blog scaffold
Add the CLI, site, and sample content so the project can run locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d2628b318
commit
b78f4b39c9
40 changed files with 9140 additions and 0 deletions
295
backend/internal/cli/util.go
Normal file
295
backend/internal/cli/util.go
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func statusCommand(status string) string {
|
||||
if status == "published" {
|
||||
return "publish"
|
||||
}
|
||||
return "unpublish"
|
||||
}
|
||||
|
||||
func uniquePath(path string) string {
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
return path
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
base := strings.TrimSuffix(path, ext)
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d%s", base, i, ext)
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext)
|
||||
}
|
||||
|
||||
func mustRel(root string, path string) string {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func stringPtrValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func parseTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, value)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
parsed, err = time.Parse("2006-01-02 15:04:05", value)
|
||||
if err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func confirm(prompt string) bool {
|
||||
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
|
||||
var answer string
|
||||
if _, err := fmt.Fscan(os.Stdin, &answer); err != nil {
|
||||
return false
|
||||
}
|
||||
answer = strings.ToLower(strings.TrimSpace(answer))
|
||||
return answer == "y" || answer == "yes"
|
||||
}
|
||||
|
||||
func resolveRootPath(root string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(root, path)
|
||||
}
|
||||
|
||||
func yesNo(value bool) string {
|
||||
if value {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func contentHash(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]int) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func sortedBoolKeys(values map[string]bool) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func uniqueSlug(root string, slug string) (string, error) {
|
||||
base := sanitizeSlug(slug)
|
||||
if base == "" {
|
||||
return "", errors.New("empty slug")
|
||||
}
|
||||
if !slugExists(root, base) {
|
||||
return base, nil
|
||||
}
|
||||
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", base, i)
|
||||
if !slugExists(root, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available slug for %q", base)
|
||||
}
|
||||
|
||||
func slugExists(root string, slug string) bool {
|
||||
posts, err := loadPosts(root)
|
||||
if err == nil {
|
||||
for _, post := range posts {
|
||||
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
|
||||
if post.Frontmatter.Slug == slug || fileSlug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func uniquePostPath(postsDir string, slug string) (string, error) {
|
||||
candidate := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i))
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available filename for slug %q", slug)
|
||||
}
|
||||
|
||||
func findProjectRoot() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for {
|
||||
if isProjectRoot(wd) {
|
||||
return wd, nil
|
||||
}
|
||||
parent := filepath.Dir(wd)
|
||||
if parent == wd {
|
||||
return "", errors.New("could not find project root")
|
||||
}
|
||||
wd = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isProjectRoot(dir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fallbackSlug(title string) string {
|
||||
var words []string
|
||||
var b strings.Builder
|
||||
|
||||
flush := func() {
|
||||
if b.Len() > 0 {
|
||||
words = append(words, b.String())
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range strings.ToLower(title) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/':
|
||||
flush()
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
if len(words) == 0 {
|
||||
return "post-" + time.Now().Format("20060102150405")
|
||||
}
|
||||
return sanitizeSlug(strings.Join(words, "-"))
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
slug = re.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
for strings.Contains(slug, "--") {
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
}
|
||||
if len(slug) > 80 {
|
||||
slug = strings.Trim(slug[:80], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("post-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func escapeYAML(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
return s
|
||||
}
|
||||
|
||||
func formatYAMLStringList(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i, value := range values {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(`"`)
|
||||
b.WriteString(escapeYAML(value))
|
||||
b.WriteString(`"`)
|
||||
}
|
||||
b.WriteString("]")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeFileIfMissing(path string, content string) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue