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>
295 lines
6.1 KiB
Go
295 lines
6.1 KiB
Go
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)
|
|
}
|