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:
yarnom 2026-05-28 16:58:30 +08:00
parent 9d2628b318
commit b78f4b39c9
40 changed files with 9140 additions and 0 deletions

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