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
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -1,3 +1,19 @@
|
|||
legacy/
|
||||
.agents/
|
||||
.codex/
|
||||
.idea/
|
||||
docs/
|
||||
|
||||
# Local runtime state
|
||||
.osaet/
|
||||
config/local.yaml
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
.astro/
|
||||
|
||||
# Go
|
||||
*.test
|
||||
|
|
|
|||
15
backend/cmd/osaetctl/main.go
Normal file
15
backend/cmd/osaetctl/main.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"osaet/backend/internal/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.Run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
24
backend/go.mod
Normal file
24
backend/go.mod
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
module osaet/backend
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.33.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
53
backend/go.sum
Normal file
53
backend/go.sum
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
|
||||
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
183
backend/internal/ai/deepseek.go
Normal file
183
backend/internal/ai/deepseek.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDeepSeekBaseURL = "https://api.deepseek.com"
|
||||
DefaultDeepSeekModel = "deepseek-v4-pro"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
}
|
||||
|
||||
type deepSeekMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type deepSeekChatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []deepSeekMessage `json:"messages"`
|
||||
Thinking map[string]string `json:"thinking,omitempty"`
|
||||
ResponseFormat map[string]string `json:"response_format,omitempty"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
type deepSeekChatResponse struct {
|
||||
Choices []struct {
|
||||
FinishReason string `json:"finish_reason"`
|
||||
Message struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type slugResponse struct {
|
||||
Slug string `json:"slug"`
|
||||
Alternatives []string `json:"alternatives"`
|
||||
}
|
||||
|
||||
func GenerateSlug(ctx context.Context, config Config, title string, summary string) (string, error) {
|
||||
apiKey := strings.TrimSpace(config.APIKey)
|
||||
if apiKey == "" {
|
||||
return "", errors.New("DeepSeek API key is empty")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(config.BaseURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultDeepSeekBaseURL
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
model = DefaultDeepSeekModel
|
||||
}
|
||||
|
||||
userPrompt := fmt.Sprintf("Title: %s\nSummary: %s", title, summary)
|
||||
payload := deepSeekChatRequest{
|
||||
Model: model,
|
||||
Messages: []deepSeekMessage{
|
||||
{
|
||||
Role: "system",
|
||||
Content: `You generate concise English URL slugs for blog posts.
|
||||
Return JSON only.
|
||||
Rules:
|
||||
- lowercase English
|
||||
- use hyphen separators
|
||||
- only a-z, 0-9, and hyphen
|
||||
- translate non-English titles by meaning into natural English
|
||||
- do not use pinyin, transliteration, romanization, or pronunciation-based spellings
|
||||
- no leading or trailing hyphen
|
||||
- max 80 characters
|
||||
- preserve important technical terms
|
||||
- do not include explanations
|
||||
Examples:
|
||||
- title "喜欢你" -> {"slug":"like-you","alternatives":["i-like-you","loving-you"]}
|
||||
- title "用 Go 和 Astro 构建个人博客" -> {"slug":"building-a-personal-blog-with-go-and-astro","alternatives":["go-astro-personal-blog"]}
|
||||
JSON format: {"slug":"example-slug","alternatives":["another-slug"]}`,
|
||||
},
|
||||
{Role: "user", Content: userPrompt},
|
||||
},
|
||||
Thinking: map[string]string{"type": "disabled"},
|
||||
ResponseFormat: map[string]string{"type": "json_object"},
|
||||
Temperature: 0.2,
|
||||
MaxTokens: 300,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("DeepSeek API returned %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
var chatResp deepSeekChatResponse
|
||||
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if chatResp.Error != nil {
|
||||
return "", fmt.Errorf("DeepSeek API error: %s", chatResp.Error.Message)
|
||||
}
|
||||
if len(chatResp.Choices) == 0 {
|
||||
return "", errors.New("DeepSeek API returned no choices")
|
||||
}
|
||||
|
||||
choice := chatResp.Choices[0]
|
||||
content := strings.TrimSpace(choice.Message.Content)
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("DeepSeek returned empty content, finish_reason=%q", choice.FinishReason)
|
||||
}
|
||||
|
||||
var slugResp slugResponse
|
||||
if err := json.Unmarshal([]byte(content), &slugResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse DeepSeek slug JSON: %w", err)
|
||||
}
|
||||
|
||||
slug := sanitizeSlug(slugResp.Slug)
|
||||
if slug == "" {
|
||||
for _, alternative := range slugResp.Alternatives {
|
||||
slug = sanitizeSlug(alternative)
|
||||
if slug != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if slug == "" {
|
||||
return "", errors.New("DeepSeek returned an empty slug")
|
||||
}
|
||||
return slug, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
88
backend/internal/cli/build.go
Normal file
88
backend/internal/cli/build.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"osaet/backend/internal/staticserver"
|
||||
)
|
||||
|
||||
func runBuild(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("build", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
siteDir := fs.String("site-dir", defaultAstroDir, "Astro project directory")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sitePath := *siteDir
|
||||
if !filepath.IsAbs(sitePath) {
|
||||
sitePath = filepath.Join(root, sitePath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(sitePath, "package.json")); err != nil {
|
||||
return fmt.Errorf("Astro project not found at %s; run from repo root", *siteDir)
|
||||
}
|
||||
|
||||
cmd := exec.Command("npm", "run", "build")
|
||||
cmd.Dir = sitePath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runDev(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("dev", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
host := fs.String("host", "127.0.0.1", "host to bind")
|
||||
port := fs.String("port", "4321", "port to listen on")
|
||||
siteDir := fs.String("site-dir", defaultAstroDir, "Astro project directory")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sitePath := *siteDir
|
||||
if !filepath.IsAbs(sitePath) {
|
||||
sitePath = filepath.Join(root, sitePath)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(sitePath, "package.json")); err != nil {
|
||||
return fmt.Errorf("Astro project not found at %s; run `osaetctl init` first", *siteDir)
|
||||
}
|
||||
|
||||
cmd := exec.Command("npm", "run", "dev", "--", "--host", *host, "--port", *port)
|
||||
cmd.Dir = sitePath
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runServe(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
host := fs.String("host", "127.0.0.1", "host to bind")
|
||||
port := fs.String("port", "4321", "port to listen on")
|
||||
dir := fs.String("dir", defaultBuildOutDir, "static output directory")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
staticDir := *dir
|
||||
if !filepath.IsAbs(staticDir) {
|
||||
staticDir = filepath.Join(root, staticDir)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(staticDir); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("static directory %s does not exist; run `osaetctl build` first", *dir)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return staticserver.Serve(staticDir, *host, *port)
|
||||
}
|
||||
106
backend/internal/cli/cli.go
Normal file
106
backend/internal/cli/cli.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func Run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
printUsage()
|
||||
return nil
|
||||
}
|
||||
|
||||
root, err := findProjectRoot()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "init":
|
||||
return runInit(root, args[1:])
|
||||
case "posts":
|
||||
return runPosts(root, args[1:])
|
||||
case "tags":
|
||||
return runTags(root, args[1:])
|
||||
case "db":
|
||||
return runDB(root, args[1:])
|
||||
case "config":
|
||||
return runConfig(root, args[1:])
|
||||
case "build":
|
||||
return runBuild(root, args[1:])
|
||||
case "serve":
|
||||
return runServe(root, args[1:])
|
||||
case "dev":
|
||||
return runDev(root, args[1:])
|
||||
case "help", "-h", "--help":
|
||||
printUsage()
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runInit(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("init", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dirs := []string{
|
||||
"config",
|
||||
defaultPostsDir,
|
||||
defaultAssetsDir,
|
||||
".osaet",
|
||||
defaultAstroDir,
|
||||
defaultBuildOutDir,
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeFileIfMissing(filepath.Join(root, "config/site.yaml"), defaultSiteConfig()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileIfMissing(filepath.Join(root, "config/local.example.yaml"), defaultLocalExampleConfig()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("initialized local project structure")
|
||||
return nil
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Print(`osaetctl manages local blog content and static builds.
|
||||
|
||||
Usage:
|
||||
osaetctl init
|
||||
osaetctl posts slug --title "My Post" [--summary "..."]
|
||||
osaetctl posts new --title "My Post" [--slug my-post|--no-ai-slug] [--tag go] [--summary "..."] [--status draft]
|
||||
osaetctl posts list [--status draft|published]
|
||||
osaetctl posts show <slug>
|
||||
osaetctl posts publish <slug>
|
||||
osaetctl posts unpublish <slug>
|
||||
osaetctl posts delete <slug>
|
||||
osaetctl posts edit <slug>
|
||||
osaetctl posts import [--db .osaet/osaet.db]
|
||||
osaetctl posts export [--db .osaet/osaet.db] [--overwrite]
|
||||
osaetctl posts diff [--db .osaet/osaet.db]
|
||||
osaetctl posts sync [--from files|db|auto] [--yes] [--db .osaet/osaet.db]
|
||||
osaetctl tags list [--all]
|
||||
osaetctl db init [--path .osaet/osaet.db]
|
||||
osaetctl db status [--path .osaet/osaet.db]
|
||||
osaetctl config import [--db .osaet/osaet.db]
|
||||
osaetctl config export [--db .osaet/osaet.db] [--overwrite]
|
||||
osaetctl config diff [--db .osaet/osaet.db]
|
||||
osaetctl config sync [--from file|db|auto] [--yes] [--db .osaet/osaet.db]
|
||||
osaetctl dev [--host 127.0.0.1] [--port 4321]
|
||||
osaetctl build
|
||||
osaetctl serve [--host 127.0.0.1] [--port 4321] [--dir dist/site]
|
||||
`)
|
||||
}
|
||||
117
backend/internal/cli/config.go
Normal file
117
backend/internal/cli/config.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func runConfig(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing config subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "import":
|
||||
return runConfigImport(root, args[1:])
|
||||
case "export":
|
||||
return runConfigExport(root, args[1:])
|
||||
case "diff":
|
||||
return runConfigDiff(root, args[1:])
|
||||
case "sync":
|
||||
return runConfigSync(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown config subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func loadLocalConfig(root string) (localConfig, error) {
|
||||
var config localConfig
|
||||
path := filepath.Join(root, "config/local.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return config, nil
|
||||
}
|
||||
return config, err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return config, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func readSiteConfig(root string) (siteConfigFile, error) {
|
||||
var config siteConfigFile
|
||||
path := filepath.Join(root, "config/site.yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
return config, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func writeSiteConfig(root string, config siteConfigFile) error {
|
||||
var out bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&out)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(config); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(root, "config/site.yaml")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, out.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func defaultSiteConfig() string {
|
||||
return `meta:
|
||||
config_version: 1
|
||||
updated_at: "2026-05-28T12:00:00+08:00"
|
||||
updated_by: "cli"
|
||||
|
||||
site:
|
||||
title: "Osaet"
|
||||
description: "Personal blog"
|
||||
base_url: "http://localhost:4321"
|
||||
language: "zh-CN"
|
||||
timezone: "Asia/Shanghai"
|
||||
|
||||
content:
|
||||
posts_dir: "content/posts"
|
||||
assets_dir: "content/assets"
|
||||
|
||||
build:
|
||||
astro_project: "frontend/site"
|
||||
output_dir: "dist/site"
|
||||
`
|
||||
}
|
||||
|
||||
func defaultLocalExampleConfig() string {
|
||||
return `database:
|
||||
driver: "sqlite"
|
||||
sqlite_path: ".osaet/osaet.db"
|
||||
postgres_dsn: ""
|
||||
|
||||
deepseek:
|
||||
api_key: ""
|
||||
api_key_env: "DEEPSEEK_API_KEY"
|
||||
base_url: "https://api.deepseek.com"
|
||||
model: "deepseek-v4-pro"
|
||||
`
|
||||
}
|
||||
33
backend/internal/cli/config_test.go
Normal file
33
backend/internal/cli/config_test.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestChangedConfigFields(t *testing.T) {
|
||||
var a siteConfigFile
|
||||
a.Meta.ConfigVersion = 1
|
||||
a.Meta.UpdatedAt = "2026-05-28T12:00:00+08:00"
|
||||
a.Site.Title = "A"
|
||||
a.Content.PostsDir = "content/posts"
|
||||
a.Build.OutputDir = "dist/site"
|
||||
|
||||
b := a
|
||||
if fields := changedConfigFields(a, b); len(fields) != 0 {
|
||||
t.Fatalf("unchanged config fields = %#v", fields)
|
||||
}
|
||||
|
||||
b.Site.Title = "B"
|
||||
b.Build.OutputDir = "dist/other"
|
||||
fields := changedConfigFields(a, b)
|
||||
if len(fields) != 2 || fields[0] != "site.title" || fields[1] != "build.output_dir" {
|
||||
t.Fatalf("changed fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTime(t *testing.T) {
|
||||
if _, ok := parseTime("2026-05-28T12:00:00+08:00"); !ok {
|
||||
t.Fatal("expected RFC3339 time to parse")
|
||||
}
|
||||
if _, ok := parseTime("not-time"); ok {
|
||||
t.Fatal("expected invalid time")
|
||||
}
|
||||
}
|
||||
130
backend/internal/cli/content.go
Normal file
130
backend/internal/cli/content.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func loadPosts(root string) ([]postFile, error) {
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
entries, err := os.ReadDir(postsDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var posts []postFile
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := readPostFile(filepath.Join(postsDir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func loadPostBySlug(root string, slug string) (postFile, error) {
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return postFile{}, errors.New("missing slug")
|
||||
}
|
||||
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return postFile{}, err
|
||||
}
|
||||
for _, post := range posts {
|
||||
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
|
||||
if post.Frontmatter.Slug == cleanSlug || fileSlug == cleanSlug {
|
||||
return post, nil
|
||||
}
|
||||
}
|
||||
return postFile{}, fmt.Errorf("post not found: %s", cleanSlug)
|
||||
}
|
||||
|
||||
func readPostFile(path string) (postFile, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return postFile{}, err
|
||||
}
|
||||
|
||||
frontmatter, body, err := splitFrontmatter(data)
|
||||
if err != nil {
|
||||
return postFile{}, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
|
||||
var meta postFrontmatter
|
||||
if err := yaml.Unmarshal(frontmatter, &meta); err != nil {
|
||||
return postFile{}, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if meta.Slug == "" {
|
||||
meta.Slug = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
}
|
||||
if meta.Status == "" {
|
||||
meta.Status = "draft"
|
||||
}
|
||||
|
||||
return postFile{
|
||||
Path: path,
|
||||
Frontmatter: meta,
|
||||
Body: strings.TrimPrefix(string(body), "\n"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writePostFile(post postFile) error {
|
||||
var frontmatter bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&frontmatter)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(post.Frontmatter); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
output.WriteString("---\n")
|
||||
output.Write(frontmatter.Bytes())
|
||||
output.WriteString("---\n\n")
|
||||
output.WriteString(strings.TrimLeft(post.Body, "\n"))
|
||||
|
||||
tmp := post.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, post.Path)
|
||||
}
|
||||
|
||||
func splitFrontmatter(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
|
||||
}
|
||||
146
backend/internal/cli/db.go
Normal file
146
backend/internal/cli/db.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed sqlite_schema.sql
|
||||
var sqliteSchema string
|
||||
|
||||
func runDB(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing db subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "init":
|
||||
return runDBInit(root, args[1:])
|
||||
case "status":
|
||||
return runDBStatus(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown db subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runDBInit(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("db init", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
path := fs.String("path", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *path)
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("initialized SQLite database: %s\n", mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDBStatus(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("db status", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
path := fs.String("path", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *path)
|
||||
info, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Printf("database: %s\n", mustRel(root, dbPath))
|
||||
fmt.Println("exists: no")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fmt.Printf("database: %s\n", mustRel(root, dbPath))
|
||||
fmt.Println("exists: yes")
|
||||
fmt.Printf("size: %d bytes\n", info.Size())
|
||||
for _, table := range []string{"posts", "settings", "sync_state"} {
|
||||
ok, err := sqliteTableExists(db, table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("table %-10s %s\n", table+":", yesNo(ok))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openSQLite(path string) (*sql.DB, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func openProjectSQLite(root string, path string) (*sql.DB, string, error) {
|
||||
dbPath := resolveRootPath(root, path)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, "", fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
db.Close()
|
||||
return nil, "", err
|
||||
}
|
||||
return db, dbPath, nil
|
||||
}
|
||||
|
||||
func applySQLiteSchema(db *sql.DB) error {
|
||||
for _, statement := range strings.Split(sqliteSchema, ";") {
|
||||
statement = strings.TrimSpace(statement)
|
||||
if statement == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteTableExists(db *sql.DB, table string) (bool, error) {
|
||||
var count int
|
||||
err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
57
backend/internal/cli/markdown_test.go
Normal file
57
backend/internal/cli/markdown_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadWritePostFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
publishedAt := "2026-05-28T12:00:00+08:00"
|
||||
post := postFile{
|
||||
Path: filepath.Join(dir, "hello.md"),
|
||||
Frontmatter: postFrontmatter{
|
||||
ID: "post-1",
|
||||
Slug: "hello",
|
||||
Title: "Hello",
|
||||
Summary: "Summary",
|
||||
Status: "published",
|
||||
Tags: []string{"go", "astro"},
|
||||
Version: 2,
|
||||
SlugSource: "manual",
|
||||
PublishedAt: &publishedAt,
|
||||
CreatedAt: "2026-05-28T11:00:00+08:00",
|
||||
UpdatedAt: "2026-05-28T12:00:00+08:00",
|
||||
},
|
||||
Body: "Body\n",
|
||||
}
|
||||
|
||||
if err := writePostFile(post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := readPostFile(post.Path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Frontmatter.ID != post.Frontmatter.ID || got.Frontmatter.Slug != post.Frontmatter.Slug {
|
||||
t.Fatalf("frontmatter mismatch: %#v", got.Frontmatter)
|
||||
}
|
||||
if got.Body != post.Body {
|
||||
t.Fatalf("body = %q, want %q", got.Body, post.Body)
|
||||
}
|
||||
if len(got.Frontmatter.Tags) != 2 || got.Frontmatter.Tags[1] != "astro" {
|
||||
t.Fatalf("tags = %#v", got.Frontmatter.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPostFileRequiresFrontmatter(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.md")
|
||||
if err := os.WriteFile(path, []byte("no frontmatter"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readPostFile(path); err == nil {
|
||||
t.Fatal("expected frontmatter error")
|
||||
}
|
||||
}
|
||||
368
backend/internal/cli/posts.go
Normal file
368
backend/internal/cli/posts.go
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"osaet/backend/internal/ai"
|
||||
)
|
||||
|
||||
func runPosts(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing posts subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "new":
|
||||
return runPostsNew(root, args[1:])
|
||||
case "slug":
|
||||
return runPostsSlug(root, args[1:])
|
||||
case "list":
|
||||
return runPostsList(root, args[1:])
|
||||
case "show":
|
||||
return runPostsShow(root, args[1:])
|
||||
case "publish":
|
||||
return runPostsStatus(root, args[1:], "published")
|
||||
case "unpublish":
|
||||
return runPostsStatus(root, args[1:], "draft")
|
||||
case "delete":
|
||||
return runPostsDelete(root, args[1:])
|
||||
case "edit":
|
||||
return runPostsEdit(root, args[1:])
|
||||
case "import":
|
||||
return runPostsImport(root, args[1:])
|
||||
case "export":
|
||||
return runPostsExport(root, args[1:])
|
||||
case "diff":
|
||||
return runPostsDiff(root, args[1:])
|
||||
case "sync":
|
||||
return runPostsSync(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown posts subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runTags(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing tags subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return runTagsList(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown tags subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func runTagsList(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("tags list", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
all := fs.Bool("all", false, "include draft posts")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, post := range posts {
|
||||
if !*all && post.Frontmatter.Status != "published" {
|
||||
continue
|
||||
}
|
||||
for _, tag := range post.Frontmatter.Tags {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
counts[tag]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tag := range sortedKeys(counts) {
|
||||
fmt.Printf("%-24s %d\n", tag, counts[tag])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsNew(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts new", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
title := fs.String("title", "", "post title")
|
||||
slug := fs.String("slug", "", "post slug")
|
||||
status := fs.String("status", "draft", "post status: draft or published")
|
||||
summary := fs.String("summary", "", "post summary")
|
||||
body := fs.String("body", "", "initial markdown body")
|
||||
aiSlug := fs.Bool("ai-slug", true, "generate slug with DeepSeek when --slug is empty")
|
||||
noAISlug := fs.Bool("no-ai-slug", false, "disable AI slug generation")
|
||||
tags := stringListFlag{}
|
||||
fs.Var(&tags, "tag", "post tag; may be repeated")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*title) == "" {
|
||||
return errors.New("missing required --title")
|
||||
}
|
||||
|
||||
cleanSlug := ""
|
||||
slugSource := "manual"
|
||||
if strings.TrimSpace(*slug) != "" {
|
||||
cleanSlug = sanitizeSlug(*slug)
|
||||
} else if *aiSlug && !*noAISlug {
|
||||
generatedSlug, err := generateDeepSeekSlug(context.Background(), root, *title, *summary)
|
||||
if err == nil {
|
||||
cleanSlug = generatedSlug
|
||||
slugSource = "ai"
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "warning: AI slug generation failed, using local fallback: %v\n", err)
|
||||
}
|
||||
}
|
||||
if cleanSlug == "" {
|
||||
cleanSlug = fallbackSlug(*title)
|
||||
}
|
||||
if cleanSlug == "" {
|
||||
return errors.New("could not derive slug; pass --slug")
|
||||
}
|
||||
cleanSlug, err := uniqueSlug(root, cleanSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if *status != "draft" && *status != "published" {
|
||||
return errors.New("--status must be draft or published")
|
||||
}
|
||||
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path, err := uniquePostPath(postsDir, cleanSlug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
publishedAt := "null"
|
||||
if *status == "published" {
|
||||
publishedAt = fmt.Sprintf("%q", now)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(*body)
|
||||
if content == "" {
|
||||
content = "Write your post here."
|
||||
}
|
||||
|
||||
post := fmt.Sprintf(`---
|
||||
id: "%s"
|
||||
slug: "%s"
|
||||
title: "%s"
|
||||
summary: "%s"
|
||||
status: "%s"
|
||||
tags: %s
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: "%s"
|
||||
slug_locked: false
|
||||
published_at: %s
|
||||
created_at: "%s"
|
||||
updated_at: "%s"
|
||||
---
|
||||
|
||||
%s
|
||||
`, randomID(), cleanSlug, escapeYAML(*title), escapeYAML(*summary), *status, formatYAMLStringList(tags.Values()), slugSource, publishedAt, now, now, content)
|
||||
|
||||
if err := os.WriteFile(path, []byte(post), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
rel = path
|
||||
}
|
||||
fmt.Println(rel)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsSlug(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts slug", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
title := fs.String("title", "", "post title")
|
||||
summary := fs.String("summary", "", "optional post summary")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*title) == "" {
|
||||
return errors.New("missing required --title")
|
||||
}
|
||||
|
||||
slug, err := generateDeepSeekSlug(context.Background(), root, *title, *summary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slug, err = uniqueSlug(root, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(slug)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsList(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts list", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
status := fs.String("status", "", "filter by status")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, post := range posts {
|
||||
if *status != "" && post.Frontmatter.Status != *status {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("%-12s %-32s %-24s %s\n", post.Frontmatter.Status, post.Frontmatter.Slug, strings.Join(post.Frontmatter.Tags, ","), post.Frontmatter.Title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsShow(root string, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: osaetctl posts show <slug>")
|
||||
}
|
||||
|
||||
post, err := loadPostBySlug(root, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("title: %s\n", post.Frontmatter.Title)
|
||||
fmt.Printf("slug: %s\n", post.Frontmatter.Slug)
|
||||
fmt.Printf("status: %s\n", post.Frontmatter.Status)
|
||||
fmt.Printf("summary: %s\n", post.Frontmatter.Summary)
|
||||
fmt.Printf("tags: %s\n", strings.Join(post.Frontmatter.Tags, ", "))
|
||||
fmt.Printf("path: %s\n", mustRel(root, post.Path))
|
||||
fmt.Println()
|
||||
fmt.Println(post.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsStatus(root string, args []string, status string) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("usage: osaetctl posts %s <slug>", statusCommand(status))
|
||||
}
|
||||
|
||||
post, err := loadPostBySlug(root, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
post.Frontmatter.Status = status
|
||||
post.Frontmatter.UpdatedAt = now
|
||||
post.Frontmatter.Version++
|
||||
if status == "published" && post.Frontmatter.PublishedAt == nil {
|
||||
post.Frontmatter.PublishedAt = &now
|
||||
}
|
||||
if status == "draft" {
|
||||
post.Frontmatter.PublishedAt = nil
|
||||
}
|
||||
|
||||
if err := writePostFile(post); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s -> %s\n", post.Frontmatter.Slug, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsDelete(root string, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: osaetctl posts delete <slug>")
|
||||
}
|
||||
|
||||
post, err := loadPostBySlug(root, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
trashDir := filepath.Join(root, defaultTrashDir)
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
target := uniquePath(filepath.Join(trashDir, filepath.Base(post.Path)))
|
||||
if err := os.Rename(post.Path, target); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("%s -> %s\n", mustRel(root, post.Path), mustRel(root, target))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsEdit(root string, args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: osaetctl posts edit <slug>")
|
||||
}
|
||||
|
||||
editor := strings.TrimSpace(os.Getenv("EDITOR"))
|
||||
if editor == "" {
|
||||
return errors.New("EDITOR is not set")
|
||||
}
|
||||
|
||||
post, err := loadPostBySlug(root, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(editor, post.Path)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func generateDeepSeekSlug(ctx context.Context, root string, title string, summary string) (string, error) {
|
||||
config, err := loadLocalConfig(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
apiKeyEnv := strings.TrimSpace(config.DeepSeek.APIKeyEnv)
|
||||
if apiKeyEnv == "" {
|
||||
apiKeyEnv = "DEEPSEEK_API_KEY"
|
||||
}
|
||||
|
||||
apiKey := strings.TrimSpace(os.Getenv(apiKeyEnv))
|
||||
if apiKey == "" {
|
||||
apiKey = strings.TrimSpace(config.DeepSeek.APIKey)
|
||||
}
|
||||
if apiKey == "" {
|
||||
return "", fmt.Errorf("%s is not set and config/local.yaml deepseek.api_key is empty", apiKeyEnv)
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("DEEPSEEK_BASE_URL")), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(config.DeepSeek.BaseURL), "/")
|
||||
}
|
||||
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(config.DeepSeek.Model)
|
||||
}
|
||||
return ai.GenerateSlug(ctx, ai.Config{
|
||||
APIKey: apiKey,
|
||||
BaseURL: baseURL,
|
||||
Model: model,
|
||||
}, title, summary)
|
||||
}
|
||||
38
backend/internal/cli/slug_test.go
Normal file
38
backend/internal/cli/slug_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeSlug(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"Hello Astro": "hello-astro",
|
||||
" Go / Astro_Blog! ": "go-astro-blog",
|
||||
"Already--Clean": "already-clean",
|
||||
"喜欢你": "",
|
||||
"abc123": "abc123",
|
||||
}
|
||||
|
||||
for input, want := range tests {
|
||||
if got := sanitizeSlug(input); got != want {
|
||||
t.Fatalf("sanitizeSlug(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackSlug(t *testing.T) {
|
||||
if got := fallbackSlug("Hello Astro"); got != "hello-astro" {
|
||||
t.Fatalf("fallbackSlug english = %q", got)
|
||||
}
|
||||
|
||||
got := fallbackSlug("喜欢你")
|
||||
if len(got) <= len("post-") || got[:5] != "post-" {
|
||||
t.Fatalf("fallbackSlug non-ascii = %q, want post-*", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatYAMLStringList(t *testing.T) {
|
||||
got := formatYAMLStringList([]string{"go", `astro "site"`})
|
||||
want := `["go", "astro \"site\""]`
|
||||
if got != want {
|
||||
t.Fatalf("formatYAMLStringList = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
35
backend/internal/cli/sqlite_schema.sql
Normal file
35
backend/internal/cli/sqlite_schema.sql
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
content_markdown TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
cover TEXT NOT NULL DEFAULT '',
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
content_hash TEXT NOT NULL DEFAULT '',
|
||||
slug_source TEXT NOT NULL DEFAULT 'manual',
|
||||
slug_locked INTEGER NOT NULL DEFAULT 0,
|
||||
published_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published_at ON posts(published_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
68
backend/internal/cli/storage_test.go
Normal file
68
backend/internal/cli/storage_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLitePostImportLoadExport(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
db, err := openSQLite(filepath.Join(root, ".osaet.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
publishedAt := "2026-05-28T12:00:00+08:00"
|
||||
post := postFile{
|
||||
Path: filepath.Join(root, defaultPostsDir, "hello.md"),
|
||||
Frontmatter: postFrontmatter{
|
||||
ID: "post-1",
|
||||
Slug: "hello",
|
||||
Title: "Hello",
|
||||
Status: "published",
|
||||
Tags: []string{"go"},
|
||||
Version: 1,
|
||||
SlugSource: "manual",
|
||||
PublishedAt: &publishedAt,
|
||||
CreatedAt: "2026-05-28T11:00:00+08:00",
|
||||
UpdatedAt: "2026-05-28T12:00:00+08:00",
|
||||
},
|
||||
Body: "Body\n",
|
||||
}
|
||||
|
||||
if err := upsertSQLitePost(root, db, post); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
posts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("loaded %d posts, want 1", len(posts))
|
||||
}
|
||||
if posts[0].Frontmatter.Slug != "hello" || posts[0].Body != "Body\n" {
|
||||
t.Fatalf("loaded post mismatch: %#v", posts[0])
|
||||
}
|
||||
|
||||
exported, skipped, err := exportPostsToFilesCount(root, posts, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exported != 1 || skipped != 0 {
|
||||
t.Fatalf("exported=%d skipped=%d", exported, skipped)
|
||||
}
|
||||
|
||||
read, err := readPostFile(filepath.Join(root, defaultPostsDir, "hello.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if read.Frontmatter.Title != "Hello" {
|
||||
t.Fatalf("exported title = %q", read.Frontmatter.Title)
|
||||
}
|
||||
}
|
||||
845
backend/internal/cli/sync.go
Normal file
845
backend/internal/cli/sync.go
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runConfigImport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config import", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, dbPath, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
config, err := readSiteConfig(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSetting(db, "site", config.Meta.ConfigVersion, config.Meta.UpdatedAt, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("imported config/site.yaml into %s\n", mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigExport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config export", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
overwrite := fs.Bool("overwrite", false, "overwrite config/site.yaml")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
config, ok, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("settings.site not found; run `osaetctl config import` first")
|
||||
}
|
||||
|
||||
path := filepath.Join(root, "config/site.yaml")
|
||||
if _, err := os.Stat(path); err == nil && !*overwrite {
|
||||
return errors.New("config/site.yaml exists; pass --overwrite to replace it")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeSiteConfig(root, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("exported settings.site into config/site.yaml")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigDiff(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return printConfigDiff(root, db)
|
||||
}
|
||||
|
||||
func runConfigSync(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config sync", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
from := fs.String("from", "", "sync source: file, db, or auto")
|
||||
yes := fs.Bool("yes", false, "confirm automatic sync without prompting")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch *from {
|
||||
case "file", "files":
|
||||
return syncConfigFromFile(root, db)
|
||||
case "db":
|
||||
return syncConfigFromDB(root, db)
|
||||
case "auto":
|
||||
return syncConfigAuto(root, db)
|
||||
case "":
|
||||
if err := printConfigDiff(root, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !*yes && !confirm("Auto Sync config by newer updated_at?") {
|
||||
fmt.Println("No changes applied.")
|
||||
fmt.Println("Use `osaetctl config sync --from file` to write config/site.yaml into SQLite.")
|
||||
fmt.Println("Use `osaetctl config sync --from db` to write SQLite settings into config/site.yaml.")
|
||||
return nil
|
||||
}
|
||||
return syncConfigAuto(root, db)
|
||||
default:
|
||||
return errors.New("--from must be file, db, or auto")
|
||||
}
|
||||
}
|
||||
|
||||
func runPostsImport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts import", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *dbPathFlag)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imported := 0
|
||||
for _, post := range posts {
|
||||
if post.Frontmatter.ID == "" {
|
||||
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
|
||||
}
|
||||
if post.Frontmatter.Slug == "" {
|
||||
return fmt.Errorf("%s: missing slug", mustRel(root, post.Path))
|
||||
}
|
||||
if err := upsertSQLitePost(root, db, post); err != nil {
|
||||
return err
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
fmt.Printf("imported %d post(s) into %s\n", imported, mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsExport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts export", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
overwrite := fs.Bool("overwrite", false, "overwrite existing Markdown files")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *dbPathFlag)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
posts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exported, skipped, err := exportPostsToFilesCount(root, posts, *overwrite)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("exported %d post(s), skipped %d existing file(s)\n", exported, skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsDiff(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts diff", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *dbPathFlag)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
return printPostsDiff(root, db)
|
||||
}
|
||||
|
||||
func printPostsDiff(root string, db *sql.DB) error {
|
||||
filePosts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbPosts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileByID := map[string]postFile{}
|
||||
dbByID := map[string]postFile{}
|
||||
for _, post := range filePosts {
|
||||
if post.Frontmatter.ID == "" {
|
||||
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
|
||||
}
|
||||
fileByID[post.Frontmatter.ID] = post
|
||||
}
|
||||
for _, post := range dbPosts {
|
||||
dbByID[post.Frontmatter.ID] = post
|
||||
}
|
||||
|
||||
ids := map[string]bool{}
|
||||
for id := range fileByID {
|
||||
ids[id] = true
|
||||
}
|
||||
for id := range dbByID {
|
||||
ids[id] = true
|
||||
}
|
||||
|
||||
summary := map[string]int{}
|
||||
for _, id := range sortedBoolKeys(ids) {
|
||||
filePost, inFile := fileByID[id]
|
||||
dbPost, inDB := dbByID[id]
|
||||
switch {
|
||||
case inFile && !inDB:
|
||||
summary["only-file"]++
|
||||
fmt.Printf("only-file %-32s %s\n", filePost.Frontmatter.Slug, filePost.Frontmatter.Title)
|
||||
case !inFile && inDB:
|
||||
summary["only-db"]++
|
||||
fmt.Printf("only-db %-32s %s\n", dbPost.Frontmatter.Slug, dbPost.Frontmatter.Title)
|
||||
default:
|
||||
if postsEquivalent(root, filePost, dbPost) {
|
||||
summary["same"]++
|
||||
} else {
|
||||
summary["changed"]++
|
||||
fmt.Printf("changed %-32s %s\n", filePost.Frontmatter.Slug, filePost.Frontmatter.Title)
|
||||
printPostDiffFields(root, filePost, dbPost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("summary: same=%d changed=%d only-file=%d only-db=%d\n",
|
||||
summary["same"],
|
||||
summary["changed"],
|
||||
summary["only-file"],
|
||||
summary["only-db"],
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runPostsSync(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts sync", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
from := fs.String("from", "", "sync source: files, db, or auto")
|
||||
yes := fs.Bool("yes", false, "confirm automatic sync without prompting")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := resolveRootPath(root, *dbPathFlag)
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := openSQLite(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := applySQLiteSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch *from {
|
||||
case "files":
|
||||
return syncFromFiles(root, db, dbPath)
|
||||
case "db":
|
||||
return syncFromDB(root, db)
|
||||
case "auto":
|
||||
return syncAuto(root, db)
|
||||
case "":
|
||||
if err := printPostsDiff(root, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !*yes && !confirm("Auto Sync by newer updated_at?") {
|
||||
fmt.Println("No changes applied.")
|
||||
fmt.Println("Use `osaetctl posts sync --from files` to write Markdown into SQLite.")
|
||||
fmt.Println("Use `osaetctl posts sync --from db` to write SQLite into Markdown.")
|
||||
return nil
|
||||
}
|
||||
return syncAuto(root, db)
|
||||
default:
|
||||
return errors.New("--from must be files, db, or auto")
|
||||
}
|
||||
}
|
||||
|
||||
func syncFromFiles(root string, db *sql.DB, dbPath string) error {
|
||||
posts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, post := range posts {
|
||||
if post.Frontmatter.ID == "" {
|
||||
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
|
||||
}
|
||||
if err := upsertSQLitePost(root, db, post); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("synced %d file post(s) into %s\n", len(posts), mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncFromDB(root string, db *sql.DB) error {
|
||||
posts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := exportPostsToFiles(root, posts, true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("synced %d db post(s) into Markdown files\n", len(posts))
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncAuto(root string, db *sql.DB) error {
|
||||
filePosts, err := loadPosts(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dbPosts, err := loadSQLitePosts(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fileByID := map[string]postFile{}
|
||||
dbByID := map[string]postFile{}
|
||||
ids := map[string]bool{}
|
||||
for _, post := range filePosts {
|
||||
if post.Frontmatter.ID == "" {
|
||||
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
|
||||
}
|
||||
fileByID[post.Frontmatter.ID] = post
|
||||
ids[post.Frontmatter.ID] = true
|
||||
}
|
||||
for _, post := range dbPosts {
|
||||
dbByID[post.Frontmatter.ID] = post
|
||||
ids[post.Frontmatter.ID] = true
|
||||
}
|
||||
|
||||
filesToDB := 0
|
||||
dbToFiles := 0
|
||||
for _, id := range sortedBoolKeys(ids) {
|
||||
filePost, inFile := fileByID[id]
|
||||
dbPost, inDB := dbByID[id]
|
||||
|
||||
switch {
|
||||
case inFile && !inDB:
|
||||
if err := upsertSQLitePost(root, db, filePost); err != nil {
|
||||
return err
|
||||
}
|
||||
filesToDB++
|
||||
case !inFile && inDB:
|
||||
if err := exportPostsToFiles(root, []postFile{dbPost}, true); err != nil {
|
||||
return err
|
||||
}
|
||||
dbToFiles++
|
||||
case inFile && inDB:
|
||||
if postsEquivalent(root, filePost, dbPost) {
|
||||
continue
|
||||
}
|
||||
fileTime, fileOK := parseTime(filePost.Frontmatter.UpdatedAt)
|
||||
dbTime, dbOK := parseTime(dbPost.Frontmatter.UpdatedAt)
|
||||
if !fileOK && !dbOK {
|
||||
fmt.Printf("skipped %s: cannot compare updated_at\n", filePost.Frontmatter.Slug)
|
||||
continue
|
||||
}
|
||||
if fileOK && (!dbOK || fileTime.After(dbTime)) {
|
||||
if err := upsertSQLitePost(root, db, filePost); err != nil {
|
||||
return err
|
||||
}
|
||||
filesToDB++
|
||||
} else if err := exportPostsToFiles(root, []postFile{dbPost}, true); err != nil {
|
||||
return err
|
||||
} else {
|
||||
dbToFiles++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("auto sync complete: files->db=%d db->files=%d\n", filesToDB, dbToFiles)
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSetting(db *sql.DB, key string, version int, updatedAt string, value any) error {
|
||||
if version == 0 {
|
||||
version = 1
|
||||
}
|
||||
if strings.TrimSpace(updatedAt) == "" {
|
||||
updatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
valueJSON, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO settings (key, value_json, version, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value_json = excluded.value_json,
|
||||
version = excluded.version,
|
||||
updated_at = excluded.updated_at`,
|
||||
key, string(valueJSON), version, updatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadSiteSetting(db *sql.DB) (siteConfigFile, bool, error) {
|
||||
var config siteConfigFile
|
||||
var valueJSON string
|
||||
err := db.QueryRow(`SELECT value_json FROM settings WHERE key = 'site'`).Scan(&valueJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return config, false, nil
|
||||
}
|
||||
return config, false, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(valueJSON), &config); err != nil {
|
||||
return config, false, err
|
||||
}
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func printConfigDiff(root string, db *sql.DB) error {
|
||||
fileConfig, fileErr := readSiteConfig(root)
|
||||
dbConfig, dbOK, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case fileErr != nil && !errors.Is(fileErr, os.ErrNotExist):
|
||||
return fileErr
|
||||
case errors.Is(fileErr, os.ErrNotExist) && !dbOK:
|
||||
fmt.Println("summary: file=no db=no")
|
||||
case errors.Is(fileErr, os.ErrNotExist):
|
||||
fmt.Println("only-db settings.site")
|
||||
fmt.Println("summary: file=no db=yes")
|
||||
case !dbOK:
|
||||
fmt.Println("only-file config/site.yaml")
|
||||
fmt.Println("summary: file=yes db=no")
|
||||
default:
|
||||
fields := changedConfigFields(fileConfig, dbConfig)
|
||||
if len(fields) == 0 {
|
||||
fmt.Println("same config/site.yaml <-> settings.site")
|
||||
fmt.Println("summary: same=1 changed=0")
|
||||
} else {
|
||||
fmt.Println("changed config/site.yaml <-> settings.site")
|
||||
for _, field := range fields {
|
||||
fmt.Printf(" - %s\n", field)
|
||||
}
|
||||
fmt.Println("summary: same=0 changed=1")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigFromFile(root string, db *sql.DB) error {
|
||||
config, err := readSiteConfig(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSetting(db, "site", config.Meta.ConfigVersion, config.Meta.UpdatedAt, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("synced config/site.yaml into settings.site")
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigFromDB(root string, db *sql.DB) error {
|
||||
config, ok, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("settings.site not found; run `osaetctl config import` first")
|
||||
}
|
||||
if err := writeSiteConfig(root, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("synced settings.site into config/site.yaml")
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigAuto(root string, db *sql.DB) error {
|
||||
fileConfig, fileErr := readSiteConfig(root)
|
||||
dbConfig, dbOK, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case fileErr != nil && !errors.Is(fileErr, os.ErrNotExist):
|
||||
return fileErr
|
||||
case errors.Is(fileErr, os.ErrNotExist) && !dbOK:
|
||||
fmt.Println("nothing to sync")
|
||||
case errors.Is(fileErr, os.ErrNotExist):
|
||||
return syncConfigFromDB(root, db)
|
||||
case !dbOK:
|
||||
return syncConfigFromFile(root, db)
|
||||
default:
|
||||
if len(changedConfigFields(fileConfig, dbConfig)) == 0 {
|
||||
fmt.Println("config already in sync")
|
||||
return nil
|
||||
}
|
||||
fileTime, fileOK := parseTime(fileConfig.Meta.UpdatedAt)
|
||||
dbTime, dbTimeOK := parseTime(dbConfig.Meta.UpdatedAt)
|
||||
if !fileOK && !dbTimeOK {
|
||||
return errors.New("cannot auto sync config: both updated_at values are invalid")
|
||||
}
|
||||
if fileOK && (!dbTimeOK || fileTime.After(dbTime)) {
|
||||
return syncConfigFromFile(root, db)
|
||||
}
|
||||
return syncConfigFromDB(root, db)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSQLitePost(root string, db *sql.DB, post postFile) error {
|
||||
tagsJSON, err := json.Marshal(post.Frontmatter.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
publishedAt := sql.NullString{}
|
||||
if post.Frontmatter.PublishedAt != nil && strings.TrimSpace(*post.Frontmatter.PublishedAt) != "" {
|
||||
publishedAt.Valid = true
|
||||
publishedAt.String = *post.Frontmatter.PublishedAt
|
||||
}
|
||||
|
||||
_, err = db.Exec(`INSERT INTO posts (
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
summary,
|
||||
content_markdown,
|
||||
status,
|
||||
tags_json,
|
||||
cover,
|
||||
file_path,
|
||||
version,
|
||||
content_hash,
|
||||
slug_source,
|
||||
slug_locked,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
slug = excluded.slug,
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
content_markdown = excluded.content_markdown,
|
||||
status = excluded.status,
|
||||
tags_json = excluded.tags_json,
|
||||
cover = excluded.cover,
|
||||
file_path = excluded.file_path,
|
||||
version = excluded.version,
|
||||
content_hash = excluded.content_hash,
|
||||
slug_source = excluded.slug_source,
|
||||
slug_locked = excluded.slug_locked,
|
||||
published_at = excluded.published_at,
|
||||
created_at = excluded.created_at,
|
||||
updated_at = excluded.updated_at,
|
||||
deleted_at = NULL`,
|
||||
post.Frontmatter.ID,
|
||||
post.Frontmatter.Slug,
|
||||
post.Frontmatter.Title,
|
||||
post.Frontmatter.Summary,
|
||||
post.Body,
|
||||
post.Frontmatter.Status,
|
||||
string(tagsJSON),
|
||||
post.Frontmatter.Cover,
|
||||
mustRel(root, post.Path),
|
||||
post.Frontmatter.Version,
|
||||
contentHash(post.Body),
|
||||
post.Frontmatter.SlugSource,
|
||||
boolInt(post.Frontmatter.SlugLocked),
|
||||
publishedAt,
|
||||
post.Frontmatter.CreatedAt,
|
||||
post.Frontmatter.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadSQLitePosts(db *sql.DB) ([]postFile, error) {
|
||||
rows, err := db.Query(`SELECT
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
summary,
|
||||
content_markdown,
|
||||
status,
|
||||
tags_json,
|
||||
cover,
|
||||
file_path,
|
||||
version,
|
||||
slug_source,
|
||||
slug_locked,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM posts
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY COALESCE(published_at, updated_at) DESC, title ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []postFile
|
||||
for rows.Next() {
|
||||
var post postFile
|
||||
var tagsJSON string
|
||||
var publishedAt sql.NullString
|
||||
var slugLocked int
|
||||
if err := rows.Scan(
|
||||
&post.Frontmatter.ID,
|
||||
&post.Frontmatter.Slug,
|
||||
&post.Frontmatter.Title,
|
||||
&post.Frontmatter.Summary,
|
||||
&post.Body,
|
||||
&post.Frontmatter.Status,
|
||||
&tagsJSON,
|
||||
&post.Frontmatter.Cover,
|
||||
&post.Path,
|
||||
&post.Frontmatter.Version,
|
||||
&post.Frontmatter.SlugSource,
|
||||
&slugLocked,
|
||||
&publishedAt,
|
||||
&post.Frontmatter.CreatedAt,
|
||||
&post.Frontmatter.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &post.Frontmatter.Tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if publishedAt.Valid {
|
||||
post.Frontmatter.PublishedAt = &publishedAt.String
|
||||
}
|
||||
post.Frontmatter.SlugLocked = slugLocked != 0
|
||||
posts = append(posts, post)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func exportPostsToFiles(root string, posts []postFile, overwrite bool) error {
|
||||
_, _, err := exportPostsToFilesCount(root, posts, overwrite)
|
||||
return err
|
||||
}
|
||||
|
||||
func exportPostsToFilesCount(root string, posts []postFile, overwrite bool) (int, int, error) {
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
exported := 0
|
||||
skipped := 0
|
||||
for _, post := range posts {
|
||||
if post.Frontmatter.Slug == "" {
|
||||
return exported, skipped, fmt.Errorf("post %s has empty slug", post.Frontmatter.ID)
|
||||
}
|
||||
|
||||
path := filepath.Join(postsDir, sanitizeSlug(post.Frontmatter.Slug)+".md")
|
||||
post.Path = path
|
||||
if _, err := os.Stat(path); err == nil && !overwrite {
|
||||
skipped++
|
||||
continue
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return exported, skipped, err
|
||||
}
|
||||
|
||||
if err := writePostFile(post); err != nil {
|
||||
return exported, skipped, err
|
||||
}
|
||||
exported++
|
||||
}
|
||||
return exported, skipped, nil
|
||||
}
|
||||
|
||||
func postsEquivalent(root string, filePost postFile, dbPost postFile) bool {
|
||||
return len(changedPostFields(root, filePost, dbPost)) == 0
|
||||
}
|
||||
|
||||
func printPostDiffFields(root string, filePost postFile, dbPost postFile) {
|
||||
for _, field := range changedPostFields(root, filePost, dbPost) {
|
||||
fmt.Printf(" - %s\n", field)
|
||||
}
|
||||
}
|
||||
|
||||
func changedPostFields(root string, filePost postFile, dbPost postFile) []string {
|
||||
var fields []string
|
||||
if filePost.Frontmatter.Slug != dbPost.Frontmatter.Slug {
|
||||
fields = append(fields, "slug")
|
||||
}
|
||||
if filePost.Frontmatter.Title != dbPost.Frontmatter.Title {
|
||||
fields = append(fields, "title")
|
||||
}
|
||||
if filePost.Frontmatter.Summary != dbPost.Frontmatter.Summary {
|
||||
fields = append(fields, "summary")
|
||||
}
|
||||
if filePost.Frontmatter.Status != dbPost.Frontmatter.Status {
|
||||
fields = append(fields, "status")
|
||||
}
|
||||
if strings.Join(filePost.Frontmatter.Tags, "\x00") != strings.Join(dbPost.Frontmatter.Tags, "\x00") {
|
||||
fields = append(fields, "tags")
|
||||
}
|
||||
if filePost.Frontmatter.Cover != dbPost.Frontmatter.Cover {
|
||||
fields = append(fields, "cover")
|
||||
}
|
||||
if filePost.Frontmatter.Version != dbPost.Frontmatter.Version {
|
||||
fields = append(fields, "version")
|
||||
}
|
||||
if filePost.Frontmatter.SlugSource != dbPost.Frontmatter.SlugSource {
|
||||
fields = append(fields, "slug_source")
|
||||
}
|
||||
if filePost.Frontmatter.SlugLocked != dbPost.Frontmatter.SlugLocked {
|
||||
fields = append(fields, "slug_locked")
|
||||
}
|
||||
if stringPtrValue(filePost.Frontmatter.PublishedAt) != stringPtrValue(dbPost.Frontmatter.PublishedAt) {
|
||||
fields = append(fields, "published_at")
|
||||
}
|
||||
if filePost.Frontmatter.CreatedAt != dbPost.Frontmatter.CreatedAt {
|
||||
fields = append(fields, "created_at")
|
||||
}
|
||||
if filePost.Frontmatter.UpdatedAt != dbPost.Frontmatter.UpdatedAt {
|
||||
fields = append(fields, "updated_at")
|
||||
}
|
||||
if contentHash(filePost.Body) != contentHash(dbPost.Body) {
|
||||
fields = append(fields, "content")
|
||||
}
|
||||
filePath := mustRel(root, filePost.Path)
|
||||
if dbPost.Path != "" && filePath != dbPost.Path {
|
||||
fields = append(fields, "file_path")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func changedConfigFields(fileConfig siteConfigFile, dbConfig siteConfigFile) []string {
|
||||
var fields []string
|
||||
if fileConfig.Meta.ConfigVersion != dbConfig.Meta.ConfigVersion {
|
||||
fields = append(fields, "meta.config_version")
|
||||
}
|
||||
if fileConfig.Meta.UpdatedAt != dbConfig.Meta.UpdatedAt {
|
||||
fields = append(fields, "meta.updated_at")
|
||||
}
|
||||
if fileConfig.Meta.UpdatedBy != dbConfig.Meta.UpdatedBy {
|
||||
fields = append(fields, "meta.updated_by")
|
||||
}
|
||||
if fileConfig.Site.Title != dbConfig.Site.Title {
|
||||
fields = append(fields, "site.title")
|
||||
}
|
||||
if fileConfig.Site.Description != dbConfig.Site.Description {
|
||||
fields = append(fields, "site.description")
|
||||
}
|
||||
if fileConfig.Site.BaseURL != dbConfig.Site.BaseURL {
|
||||
fields = append(fields, "site.base_url")
|
||||
}
|
||||
if fileConfig.Site.Language != dbConfig.Site.Language {
|
||||
fields = append(fields, "site.language")
|
||||
}
|
||||
if fileConfig.Site.Timezone != dbConfig.Site.Timezone {
|
||||
fields = append(fields, "site.timezone")
|
||||
}
|
||||
if fileConfig.Content.PostsDir != dbConfig.Content.PostsDir {
|
||||
fields = append(fields, "content.posts_dir")
|
||||
}
|
||||
if fileConfig.Content.AssetsDir != dbConfig.Content.AssetsDir {
|
||||
fields = append(fields, "content.assets_dir")
|
||||
}
|
||||
if fileConfig.Build.AstroProject != dbConfig.Build.AstroProject {
|
||||
fields = append(fields, "build.astro_project")
|
||||
}
|
||||
if fileConfig.Build.OutputDir != dbConfig.Build.OutputDir {
|
||||
fields = append(fields, "build.output_dir")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
87
backend/internal/cli/types.go
Normal file
87
backend/internal/cli/types.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package cli
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
defaultPostsDir = "content/posts"
|
||||
defaultAssetsDir = "content/assets"
|
||||
defaultTrashDir = "content/.trash/posts"
|
||||
defaultAstroDir = "frontend/site"
|
||||
defaultBuildOutDir = "dist/site"
|
||||
defaultSQLitePath = ".osaet/osaet.db"
|
||||
)
|
||||
|
||||
type postFile struct {
|
||||
Path string
|
||||
Frontmatter postFrontmatter
|
||||
Body 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"`
|
||||
}
|
||||
|
||||
type localConfig struct {
|
||||
DeepSeek struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
APIKeyEnv string `yaml:"api_key_env"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Model string `yaml:"model"`
|
||||
} `yaml:"deepseek"`
|
||||
}
|
||||
|
||||
type siteConfigFile struct {
|
||||
Meta struct {
|
||||
ConfigVersion int `yaml:"config_version" json:"config_version"`
|
||||
UpdatedAt string `yaml:"updated_at" json:"updated_at"`
|
||||
UpdatedBy string `yaml:"updated_by" json:"updated_by"`
|
||||
} `yaml:"meta" json:"meta"`
|
||||
Site struct {
|
||||
Title string `yaml:"title" json:"title"`
|
||||
Description string `yaml:"description" json:"description"`
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
Language string `yaml:"language" json:"language"`
|
||||
Timezone string `yaml:"timezone" json:"timezone"`
|
||||
} `yaml:"site" json:"site"`
|
||||
Content struct {
|
||||
PostsDir string `yaml:"posts_dir" json:"posts_dir"`
|
||||
AssetsDir string `yaml:"assets_dir" json:"assets_dir"`
|
||||
} `yaml:"content" json:"content"`
|
||||
Build struct {
|
||||
AstroProject string `yaml:"astro_project" json:"astro_project"`
|
||||
OutputDir string `yaml:"output_dir" json:"output_dir"`
|
||||
} `yaml:"build" json:"build"`
|
||||
}
|
||||
|
||||
type stringListFlag struct {
|
||||
values []string
|
||||
}
|
||||
|
||||
func (f *stringListFlag) String() string {
|
||||
return strings.Join(f.values, ",")
|
||||
}
|
||||
|
||||
func (f *stringListFlag) Set(value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
f.values = append(f.values, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *stringListFlag) Values() []string {
|
||||
return append([]string(nil), f.values...)
|
||||
}
|
||||
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)
|
||||
}
|
||||
45
backend/internal/staticserver/server.go
Normal file
45
backend/internal/staticserver/server.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package staticserver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func Serve(dir string, host string, port string) error {
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("static path %s is not a directory", dir)
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := filepath.Clean("/" + r.URL.Path)
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, filepath.FromSlash(path))
|
||||
if info, err := os.Stat(fullPath); err == nil && info.IsDir() {
|
||||
fullPath = filepath.Join(fullPath, "index.html")
|
||||
} else if errors.Is(err, os.ErrNotExist) && filepath.Ext(fullPath) == "" {
|
||||
fullPath = filepath.Join(dir, filepath.FromSlash(path), "index.html")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(fullPath); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, fullPath)
|
||||
})
|
||||
|
||||
fmt.Printf("serving %s at http://%s\n", dir, addr)
|
||||
return http.ListenAndServe(addr, handler)
|
||||
}
|
||||
10
config/local.example.yaml
Normal file
10
config/local.example.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
database:
|
||||
driver: "sqlite"
|
||||
sqlite_path: ".osaet/osaet.db"
|
||||
postgres_dsn: ""
|
||||
|
||||
deepseek:
|
||||
api_key: ""
|
||||
api_key_env: "DEEPSEEK_API_KEY"
|
||||
base_url: "https://api.deepseek.com"
|
||||
model: "deepseek-v4-pro"
|
||||
19
config/site.yaml
Normal file
19
config/site.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
meta:
|
||||
config_version: 1
|
||||
updated_at: "2026-05-28T12:00:00+08:00"
|
||||
updated_by: "cli"
|
||||
|
||||
site:
|
||||
title: "Osaet"
|
||||
description: "Personal blog"
|
||||
base_url: "http://localhost:4321"
|
||||
language: "zh-CN"
|
||||
timezone: "Asia/Shanghai"
|
||||
|
||||
content:
|
||||
posts_dir: "content/posts"
|
||||
assets_dir: "content/assets"
|
||||
|
||||
build:
|
||||
astro_project: "frontend/site"
|
||||
output_dir: "dist/site"
|
||||
1
content/assets/.gitkeep
Normal file
1
content/assets/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
1
content/posts/.gitkeep
Normal file
1
content/posts/.gitkeep
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
17
content/posts/go-astro-personal-blog.md
Normal file
17
content/posts/go-astro-personal-blog.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
id: "e66587e776a53f83789175fa89221a84"
|
||||
slug: "go-astro-personal-blog"
|
||||
title: "用 Go 和 Astro 构建个人博客"
|
||||
summary: "第一篇测试文章"
|
||||
status: "published"
|
||||
tags: []
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: "manual"
|
||||
slug_locked: false
|
||||
published_at: "2026-05-28T11:42:01+08:00"
|
||||
created_at: "2026-05-28T11:42:01+08:00"
|
||||
updated_at: "2026-05-28T11:42:01+08:00"
|
||||
---
|
||||
|
||||
这里开始写正文。
|
||||
17
content/posts/my-post.md
Normal file
17
content/posts/my-post.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
id: "107d00b2c9abbfffc0a5f5f407e89834"
|
||||
slug: "my-post"
|
||||
title: "My Post"
|
||||
summary: ""
|
||||
status: "published"
|
||||
tags: ["go", "astro"]
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: "manual"
|
||||
slug_locked: false
|
||||
published_at: "2026-05-28T12:13:37+08:00"
|
||||
created_at: "2026-05-28T12:13:37+08:00"
|
||||
updated_at: "2026-05-28T12:13:37+08:00"
|
||||
---
|
||||
|
||||
Write your post here.
|
||||
17
content/posts/xi-huan-ni.md
Normal file
17
content/posts/xi-huan-ni.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
id: "7244cd44db756b30362c585703ded1fb"
|
||||
slug: "xi-huan-ni"
|
||||
title: "喜欢你"
|
||||
summary: ""
|
||||
status: "draft"
|
||||
tags: ["go"]
|
||||
cover: ""
|
||||
version: 1
|
||||
slug_source: "ai"
|
||||
slug_locked: false
|
||||
published_at: null
|
||||
created_at: "2026-05-28T13:23:35+08:00"
|
||||
updated_at: "2026-05-28T13:23:35+08:00"
|
||||
---
|
||||
|
||||
Write your post here.
|
||||
6
frontend/site/astro.config.mjs
Normal file
6
frontend/site/astro.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
outDir: '../../dist/site'
|
||||
});
|
||||
5591
frontend/site/package-lock.json
generated
Normal file
5591
frontend/site/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
frontend/site/package.json
Normal file
17
frontend/site/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@osaet/site",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"astro": "^5.0.0",
|
||||
"yaml": "^2.8.0"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
14
frontend/site/src/components/SiteNav.astro
Normal file
14
frontend/site/src/components/SiteNav.astro
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
const links = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/archive/', label: 'Archive' },
|
||||
{ href: '/tags/', label: 'Tags' },
|
||||
{ href: '/rss.xml', label: 'RSS' }
|
||||
];
|
||||
---
|
||||
|
||||
<nav class="site-nav" aria-label="Primary">
|
||||
{links.map((link) => (
|
||||
<a href={link.href}>{link.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
89
frontend/site/src/lib/posts.ts
Normal file
89
frontend/site/src/lib/posts.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
type MarkdownPost = {
|
||||
frontmatter: {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
tags?: string[];
|
||||
published_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Post = MarkdownPost['frontmatter'] & {
|
||||
url: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type TagSummary = {
|
||||
name: string;
|
||||
slug: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type ArchiveYear = {
|
||||
year: string;
|
||||
posts: Post[];
|
||||
};
|
||||
|
||||
export function getPublishedPosts(): Post[] {
|
||||
const modules = import.meta.glob('../../../../content/posts/*.md', { eager: true });
|
||||
|
||||
return Object.values(modules)
|
||||
.map((post) => {
|
||||
const frontmatter = (post as MarkdownPost).frontmatter;
|
||||
return {
|
||||
...frontmatter,
|
||||
url: `/posts/${frontmatter.slug}/`,
|
||||
date: frontmatter.published_at ?? frontmatter.updated_at ?? '',
|
||||
tags: frontmatter.tags ?? []
|
||||
};
|
||||
})
|
||||
.filter((post) => post.status === 'published')
|
||||
.sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
||||
}
|
||||
|
||||
export function tagSlug(tag: string): string {
|
||||
return encodeURIComponent(tag.trim().toLowerCase().replace(/\s+/g, '-'));
|
||||
}
|
||||
|
||||
export function getTagSummaries(): TagSummary[] {
|
||||
const counts = new Map<string, TagSummary>();
|
||||
|
||||
for (const post of getPublishedPosts()) {
|
||||
for (const tag of post.tags) {
|
||||
const name = tag.trim();
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const slug = tagSlug(name);
|
||||
const current = counts.get(slug);
|
||||
if (current) {
|
||||
current.count += 1;
|
||||
} else {
|
||||
counts.set(slug, { name, slug, count: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...counts.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export function getPostsByTag(slug: string): Post[] {
|
||||
return getPublishedPosts().filter((post) => post.tags.some((tag) => tagSlug(tag) === slug));
|
||||
}
|
||||
|
||||
export function getArchiveYears(): ArchiveYear[] {
|
||||
const years = new Map<string, Post[]>();
|
||||
|
||||
for (const post of getPublishedPosts()) {
|
||||
const year = post.date ? String(new Date(post.date).getFullYear()) : 'Undated';
|
||||
years.set(year, [...(years.get(year) ?? []), post]);
|
||||
}
|
||||
|
||||
return [...years.entries()]
|
||||
.sort(([a], [b]) => b.localeCompare(a))
|
||||
.map(([year, posts]) => ({ year, posts }));
|
||||
}
|
||||
60
frontend/site/src/lib/siteConfig.ts
Normal file
60
frontend/site/src/lib/siteConfig.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import YAML from 'yaml';
|
||||
|
||||
const siteRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
|
||||
|
||||
type SiteConfig = {
|
||||
site: {
|
||||
title: string;
|
||||
description: string;
|
||||
base_url: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
};
|
||||
content: {
|
||||
posts_dir: string;
|
||||
assets_dir: string;
|
||||
};
|
||||
build: {
|
||||
astro_project: string;
|
||||
output_dir: string;
|
||||
};
|
||||
};
|
||||
|
||||
const defaults: SiteConfig = {
|
||||
site: {
|
||||
title: 'Osaet',
|
||||
description: 'Personal blog',
|
||||
base_url: 'http://localhost:4321',
|
||||
language: 'zh-CN',
|
||||
timezone: 'Asia/Shanghai'
|
||||
},
|
||||
content: {
|
||||
posts_dir: 'content/posts',
|
||||
assets_dir: 'content/assets'
|
||||
},
|
||||
build: {
|
||||
astro_project: 'frontend/site',
|
||||
output_dir: 'dist/site'
|
||||
}
|
||||
};
|
||||
|
||||
function loadSiteConfig(): SiteConfig {
|
||||
try {
|
||||
const file = readFileSync(resolve(siteRoot, 'config/site.yaml'), 'utf8');
|
||||
const parsed = YAML.parse(file) ?? {};
|
||||
|
||||
return {
|
||||
site: { ...defaults.site, ...parsed.site },
|
||||
content: { ...defaults.content, ...parsed.content },
|
||||
build: { ...defaults.build, ...parsed.build }
|
||||
};
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export const siteConfig = loadSiteConfig();
|
||||
export const site = siteConfig.site;
|
||||
64
frontend/site/src/pages/archive/index.astro
Normal file
64
frontend/site/src/pages/archive/index.astro
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
---
|
||||
import '../../styles/global.css';
|
||||
import SiteNav from '../../components/SiteNav.astro';
|
||||
import { site } from '../../lib/siteConfig';
|
||||
import { getArchiveYears, tagSlug } from '../../lib/posts';
|
||||
|
||||
const archiveYears = getArchiveYears();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={site.language}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Archive | {site.title}</title>
|
||||
<meta name="description" content={`Archive of ${site.title}`} />
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<SiteNav />
|
||||
|
||||
<header class="site-header">
|
||||
<p class="eyebrow">{site.title}</p>
|
||||
<h1>Archive</h1>
|
||||
</header>
|
||||
|
||||
{archiveYears.length === 0 ? (
|
||||
<p class="empty">No published posts yet.</p>
|
||||
) : (
|
||||
<div class="archive-list">
|
||||
{archiveYears.map((group) => (
|
||||
<section class="archive-year" aria-label={group.year}>
|
||||
<h2>{group.year}</h2>
|
||||
<ol>
|
||||
{group.posts.map((post) => (
|
||||
<li>
|
||||
{post.date && (
|
||||
<time datetime={post.date}>
|
||||
{new Date(post.date).toLocaleDateString(site.language, {
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
<a href={post.url}>{post.title}</a>
|
||||
{post.tags.length > 0 && (
|
||||
<ul class="tags compact-tags" aria-label="Tags">
|
||||
{post.tags.map((tag) => (
|
||||
<li>
|
||||
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
59
frontend/site/src/pages/index.astro
Normal file
59
frontend/site/src/pages/index.astro
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
import '../styles/global.css';
|
||||
import SiteNav from '../components/SiteNav.astro';
|
||||
import { site } from '../lib/siteConfig';
|
||||
import { getPublishedPosts, tagSlug } from '../lib/posts';
|
||||
|
||||
const posts = getPublishedPosts();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={site.language}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>{site.title}</title>
|
||||
<meta name="description" content={site.description} />
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<SiteNav />
|
||||
|
||||
<header class="site-header">
|
||||
<p class="eyebrow">{site.description}</p>
|
||||
<h1>{site.title}</h1>
|
||||
</header>
|
||||
|
||||
<section class="post-list" aria-label="Posts">
|
||||
{posts.length === 0 ? (
|
||||
<p class="empty">No published posts yet.</p>
|
||||
) : (
|
||||
posts.map((post) => (
|
||||
<article class="post-item">
|
||||
<a href={post.url}>
|
||||
<h2>{post.title}</h2>
|
||||
{post.summary && <p>{post.summary}</p>}
|
||||
<div class="post-meta">
|
||||
{post.date && (
|
||||
<time datetime={post.date}>
|
||||
{new Date(post.date).toLocaleDateString(site.language)}
|
||||
</time>
|
||||
)}
|
||||
{post.tags.length > 0 && (
|
||||
<ul class="tags" aria-label="Tags">
|
||||
{post.tags.map((tag) => (
|
||||
<li>
|
||||
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
56
frontend/site/src/pages/posts/[slug].astro
Normal file
56
frontend/site/src/pages/posts/[slug].astro
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
import '../../styles/global.css';
|
||||
import SiteNav from '../../components/SiteNav.astro';
|
||||
import { site } from '../../lib/siteConfig';
|
||||
import { tagSlug } from '../../lib/posts';
|
||||
|
||||
export function getStaticPaths() {
|
||||
const modules = import.meta.glob('../../../../../content/posts/*.md', { eager: true });
|
||||
return Object.values(modules)
|
||||
.filter((post) => post.frontmatter.status === 'published')
|
||||
.map((post) => ({
|
||||
params: { slug: post.frontmatter.slug },
|
||||
props: { post }
|
||||
}));
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const { Content } = post;
|
||||
const title = post.frontmatter.title;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={site.language}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>{title} | {site.title}</title>
|
||||
{post.frontmatter.summary && <meta name="description" content={post.frontmatter.summary} />}
|
||||
</head>
|
||||
<body>
|
||||
<main class="page article-page">
|
||||
<SiteNav />
|
||||
|
||||
<article class="article">
|
||||
<header>
|
||||
<h1>{title}</h1>
|
||||
<time datetime={post.frontmatter.published_at ?? post.frontmatter.updated_at}>
|
||||
{new Date(post.frontmatter.published_at ?? post.frontmatter.updated_at).toLocaleDateString(site.language)}
|
||||
</time>
|
||||
{post.frontmatter.summary && <p class="summary">{post.frontmatter.summary}</p>}
|
||||
{post.frontmatter.tags?.length > 0 && (
|
||||
<ul class="tags article-tags" aria-label="Tags">
|
||||
{post.frontmatter.tags.map((tag) => (
|
||||
<li>
|
||||
<a href={`/tags/${tagSlug(tag)}/`}>{tag}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<Content />
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
18
frontend/site/src/pages/rss.xml.ts
Normal file
18
frontend/site/src/pages/rss.xml.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import { getPublishedPosts } from '../lib/posts';
|
||||
import { site } from '../lib/siteConfig';
|
||||
|
||||
export function GET() {
|
||||
return rss({
|
||||
title: site.title,
|
||||
description: site.description,
|
||||
site: site.base_url,
|
||||
items: getPublishedPosts().map((post) => ({
|
||||
title: post.title,
|
||||
description: post.summary ?? '',
|
||||
link: post.url,
|
||||
pubDate: post.date ? new Date(post.date) : undefined,
|
||||
categories: post.tags
|
||||
}))
|
||||
});
|
||||
}
|
||||
52
frontend/site/src/pages/tags/[tag].astro
Normal file
52
frontend/site/src/pages/tags/[tag].astro
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
---
|
||||
import '../../styles/global.css';
|
||||
import SiteNav from '../../components/SiteNav.astro';
|
||||
import { site } from '../../lib/siteConfig';
|
||||
import { getPostsByTag, getTagSummaries } from '../../lib/posts';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return getTagSummaries().map((tag) => ({
|
||||
params: { tag: tag.slug },
|
||||
props: { tag }
|
||||
}));
|
||||
}
|
||||
|
||||
const { tag } = Astro.props;
|
||||
const posts = getPostsByTag(tag.slug);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={site.language}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>{tag.name} | {site.title}</title>
|
||||
<meta name="description" content={`Posts tagged ${tag.name}`} />
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<SiteNav />
|
||||
|
||||
<header class="site-header">
|
||||
<p class="eyebrow">{site.title}</p>
|
||||
<h1>{tag.name}</h1>
|
||||
</header>
|
||||
|
||||
<section class="post-list" aria-label={`Posts tagged ${tag.name}`}>
|
||||
{posts.map((post) => (
|
||||
<article class="post-item">
|
||||
<a href={post.url}>
|
||||
<h2>{post.title}</h2>
|
||||
{post.summary && <p>{post.summary}</p>}
|
||||
{post.date && (
|
||||
<time datetime={post.date}>
|
||||
{new Date(post.date).toLocaleDateString(site.language)}
|
||||
</time>
|
||||
)}
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
43
frontend/site/src/pages/tags/index.astro
Normal file
43
frontend/site/src/pages/tags/index.astro
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
import '../../styles/global.css';
|
||||
import SiteNav from '../../components/SiteNav.astro';
|
||||
import { site } from '../../lib/siteConfig';
|
||||
import { getTagSummaries } from '../../lib/posts';
|
||||
|
||||
const tags = getTagSummaries();
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang={site.language}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Tags | {site.title}</title>
|
||||
<meta name="description" content={`Tags on ${site.title}`} />
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<SiteNav />
|
||||
|
||||
<header class="site-header">
|
||||
<p class="eyebrow">{site.title}</p>
|
||||
<h1>Tags</h1>
|
||||
</header>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<p class="empty">No tags yet.</p>
|
||||
) : (
|
||||
<ul class="tag-index" aria-label="Tags">
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a href={`/tags/${tag.slug}/`}>
|
||||
<span>{tag.name}</span>
|
||||
<strong>{tag.count}</strong>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
240
frontend/site/src/styles/global.css
Normal file
240
frontend/site/src/styles/global.css
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
background: #f7f5ef;
|
||||
color: #20201d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(860px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 56px 0 80px;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 18px;
|
||||
margin-bottom: 36px;
|
||||
color: #625a50;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-nav a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
border-bottom: 1px solid #d8d1c3;
|
||||
padding-bottom: 28px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #6f675b;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.4rem, 8vw, 5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
border-bottom: 1px solid #ded8cd;
|
||||
padding: 18px 0 22px;
|
||||
}
|
||||
|
||||
.post-item a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-item h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.post-item p,
|
||||
.summary,
|
||||
.empty {
|
||||
color: #5b554d;
|
||||
}
|
||||
|
||||
.post-item p {
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
time {
|
||||
color: #7a7268;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tags li {
|
||||
border: 1px solid #d0c8bb;
|
||||
border-radius: 999px;
|
||||
color: #5f584d;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.tags a {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag-index {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tag-index a {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
border-bottom: 1px solid #ded8cd;
|
||||
padding: 14px 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tag-index strong {
|
||||
color: #766f65;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-nav {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.back-nav a {
|
||||
color: #635d53;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article header {
|
||||
border-bottom: 1px solid #d8d1c3;
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.article-tags {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.archive-list {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.archive-year h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.archive-year ol {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.archive-year li {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #ded8cd;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.archive-year li > a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.compact-tags {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.archive-year li {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.compact-tags {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.article h1 {
|
||||
margin-bottom: 16px;
|
||||
font-size: clamp(2.1rem, 7vw, 4rem);
|
||||
}
|
||||
|
||||
.article h2 {
|
||||
margin-top: 2em;
|
||||
}
|
||||
|
||||
.article pre {
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #272822;
|
||||
color: #f8f8f2;
|
||||
}
|
||||
|
||||
.article code {
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue