gitblog1
This commit is contained in:
120
internal/cache/manager.go
vendored
Normal file
120
internal/cache/manager.go
vendored
Normal file
@@ -0,0 +1,120 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitblog/internal/content"
|
||||
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
cache *cache.Cache
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
func NewManager(defaultExpiration, cleanupInterval time.Duration) *Manager {
|
||||
return &Manager{
|
||||
cache: cache.New(defaultExpiration, cleanupInterval),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Set(key string, value interface{}, expiration time.Duration) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.cache.Set(key, value, expiration)
|
||||
}
|
||||
|
||||
func (m *Manager) Get(key string) (interface{}, bool) {
|
||||
m.mutex.RLock()
|
||||
defer m.mutex.RUnlock()
|
||||
return m.cache.Get(key)
|
||||
}
|
||||
|
||||
func (m *Manager) GetPost(slug string) (*content.Post, bool) {
|
||||
if item, found := m.Get("post:" + slug); found {
|
||||
if post, ok := item.(*content.Post); ok {
|
||||
return post, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Manager) SetPost(slug string, post *content.Post, expiration time.Duration) {
|
||||
m.Set("post:"+slug, post, expiration)
|
||||
}
|
||||
|
||||
func (m *Manager) GetPosts() ([]*content.Post, bool) {
|
||||
if item, found := m.Get("posts:all"); found {
|
||||
if posts, ok := item.([]*content.Post); ok {
|
||||
return posts, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Manager) SetPosts(posts []*content.Post, expiration time.Duration) {
|
||||
m.Set("posts:all", posts, expiration)
|
||||
}
|
||||
|
||||
func (m *Manager) GetPostsByCategory(category string) ([]*content.Post, bool) {
|
||||
if item, found := m.Get("posts:category:" + category); found {
|
||||
if posts, ok := item.([]*content.Post); ok {
|
||||
return posts, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Manager) SetPostsByCategory(category string, posts []*content.Post, expiration time.Duration) {
|
||||
m.Set("posts:category:"+category, posts, expiration)
|
||||
}
|
||||
|
||||
func (m *Manager) GetPage(path string) (*content.Page, bool) {
|
||||
if item, found := m.Get("page:" + path); found {
|
||||
if page, ok := item.(*content.Page); ok {
|
||||
return page, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Manager) SetPage(path string, page *content.Page, expiration time.Duration) {
|
||||
m.Set("page:"+path, page, expiration)
|
||||
}
|
||||
|
||||
func (m *Manager) GetNavigation() ([]content.Navigation, bool) {
|
||||
if item, found := m.Get("navigation"); found {
|
||||
if nav, ok := item.([]content.Navigation); ok {
|
||||
return nav, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Manager) SetNavigation(nav []content.Navigation, expiration time.Duration) {
|
||||
m.Set("navigation", nav, expiration)
|
||||
}
|
||||
|
||||
func (m *Manager) Invalidate(pattern string) {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
items := m.cache.Items()
|
||||
for key := range items {
|
||||
if pattern == "" || contains(key, pattern) {
|
||||
m.cache.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Clear() {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
m.cache.Flush()
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && s[:len(substr)] == substr
|
||||
}
|
59
internal/config/config.go
Normal file
59
internal/config/config.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string
|
||||
GitHubToken string
|
||||
GitHubOwner string
|
||||
GitHubRepo string
|
||||
CacheDuration time.Duration
|
||||
UpdateInterval time.Duration
|
||||
Theme string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Port: getEnv("PORT", "8080"),
|
||||
GitHubToken: getEnv("GITHUB_TOKEN", ""),
|
||||
GitHubOwner: getEnv("GITHUB_OWNER", ""),
|
||||
GitHubRepo: getEnv("GITHUB_REPO", ""),
|
||||
CacheDuration: getDurationEnv("CACHE_DURATION", 15*time.Minute),
|
||||
UpdateInterval: getDurationEnv("UPDATE_INTERVAL", 5*time.Minute),
|
||||
Theme: getEnv("DEFAULT_THEME", "light"),
|
||||
BaseURL: getEnv("BASE_URL", "http://localhost:8080"),
|
||||
}
|
||||
|
||||
if cfg.GitHubToken == "" {
|
||||
return nil, fmt.Errorf("GITHUB_TOKEN environment variable is required")
|
||||
}
|
||||
if cfg.GitHubOwner == "" {
|
||||
return nil, fmt.Errorf("GITHUB_OWNER environment variable is required")
|
||||
}
|
||||
if cfg.GitHubRepo == "" {
|
||||
return nil, fmt.Errorf("GITHUB_REPO environment variable is required")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getDurationEnv(key string, defaultValue time.Duration) time.Duration {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
if duration, err := time.ParseDuration(value); err == nil {
|
||||
return duration
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
82
internal/config/config_test.go
Normal file
82
internal/config/config_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
// Set test environment variables
|
||||
os.Setenv("GITHUB_TOKEN", "test-token")
|
||||
os.Setenv("GITHUB_OWNER", "test-owner")
|
||||
os.Setenv("GITHUB_REPO", "test-repo")
|
||||
os.Setenv("PORT", "9090")
|
||||
os.Setenv("CACHE_DURATION", "30m")
|
||||
os.Setenv("UPDATE_INTERVAL", "10m")
|
||||
os.Setenv("DEFAULT_THEME", "dark")
|
||||
os.Setenv("BASE_URL", "https://example.com")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if cfg.GitHubToken != "test-token" {
|
||||
t.Errorf("Expected GitHubToken to be 'test-token', got %s", cfg.GitHubToken)
|
||||
}
|
||||
|
||||
if cfg.Port != "9090" {
|
||||
t.Errorf("Expected Port to be '9090', got %s", cfg.Port)
|
||||
}
|
||||
|
||||
if cfg.CacheDuration != 30*time.Minute {
|
||||
t.Errorf("Expected CacheDuration to be 30m, got %v", cfg.CacheDuration)
|
||||
}
|
||||
|
||||
if cfg.Theme != "dark" {
|
||||
t.Errorf("Expected Theme to be 'dark', got %s", cfg.Theme)
|
||||
}
|
||||
|
||||
if cfg.BaseURL != "https://example.com" {
|
||||
t.Errorf("Expected BaseURL to be 'https://example.com', got %s", cfg.BaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingRequiredVars(t *testing.T) {
|
||||
// Clear environment variables
|
||||
os.Unsetenv("GITHUB_TOKEN")
|
||||
os.Unsetenv("GITHUB_OWNER")
|
||||
os.Unsetenv("GITHUB_REPO")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for missing required variables")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
os.Setenv("TEST_VAR", "test-value")
|
||||
defer os.Unsetenv("TEST_VAR")
|
||||
|
||||
if got := getEnv("TEST_VAR", "default"); got != "test-value" {
|
||||
t.Errorf("Expected 'test-value', got %s", got)
|
||||
}
|
||||
|
||||
if got := getEnv("NONEXISTENT_VAR", "default"); got != "default" {
|
||||
t.Errorf("Expected 'default', got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDurationEnv(t *testing.T) {
|
||||
os.Setenv("TEST_DURATION", "5m")
|
||||
defer os.Unsetenv("TEST_DURATION")
|
||||
|
||||
if got := getDurationEnv("TEST_DURATION", 1*time.Minute); got != 5*time.Minute {
|
||||
t.Errorf("Expected 5m, got %v", got)
|
||||
}
|
||||
|
||||
if got := getDurationEnv("INVALID_DURATION", 1*time.Minute); got != 1*time.Minute {
|
||||
t.Errorf("Expected 1m, got %v", got)
|
||||
}
|
||||
}
|
354
internal/content/manager.go
Normal file
354
internal/content/manager.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitblog/internal/github"
|
||||
|
||||
"github.com/russross/blackfriday/v2"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
Title string
|
||||
Slug string
|
||||
Content string
|
||||
HTML string
|
||||
Date time.Time
|
||||
Categories []string
|
||||
Tags []string
|
||||
Excerpt string
|
||||
Path string
|
||||
LastUpdated time.Time
|
||||
}
|
||||
|
||||
type Navigation struct {
|
||||
Title string
|
||||
URL string
|
||||
Order int
|
||||
}
|
||||
|
||||
type Page struct {
|
||||
Title string
|
||||
Content string
|
||||
HTML string
|
||||
Path string
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
githubClient *github.Client
|
||||
posts map[string]*Post
|
||||
pages map[string]*Page
|
||||
navigation []Navigation
|
||||
lastUpdate time.Time
|
||||
}
|
||||
|
||||
func NewManager(githubClient *github.Client) *Manager {
|
||||
return &Manager{
|
||||
githubClient: githubClient,
|
||||
posts: make(map[string]*Post),
|
||||
pages: make(map[string]*Page),
|
||||
navigation: []Navigation{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) LoadContent() error {
|
||||
if err := m.loadPosts(); err != nil {
|
||||
return fmt.Errorf("failed to load posts: %w", err)
|
||||
}
|
||||
|
||||
if err := m.loadPages(); err != nil {
|
||||
return fmt.Errorf("failed to load pages: %w", err)
|
||||
}
|
||||
|
||||
if err := m.loadNavigation(); err != nil {
|
||||
return fmt.Errorf("failed to load navigation: %w", err)
|
||||
}
|
||||
|
||||
m.lastUpdate = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadPosts() error {
|
||||
postsDir := "content/posts"
|
||||
contents, err := m.githubClient.ListDirectory(postsDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, content := range contents {
|
||||
if strings.HasSuffix(content.Path, ".md") {
|
||||
post, err := m.parsePost(content)
|
||||
if err != nil {
|
||||
// Log error but continue processing other posts
|
||||
fmt.Printf("Warning: failed to parse post %s: %v\n", content.Path, err)
|
||||
continue
|
||||
}
|
||||
if post.Slug != "" {
|
||||
m.posts[post.Slug] = post
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadPages() error {
|
||||
pagesDir := "content/pages"
|
||||
contents, err := m.githubClient.ListDirectory(pagesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, content := range contents {
|
||||
if strings.HasSuffix(content.Path, ".md") {
|
||||
page, err := m.parsePage(content)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m.pages[strings.TrimSuffix(content.Path, ".md")] = page
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadNavigation() error {
|
||||
navFile := "content/navigation.md"
|
||||
content, err := m.githubClient.GetFileContent(navFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lines := strings.Split(content.Content, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, "|", 3)
|
||||
if len(parts) >= 2 {
|
||||
nav := Navigation{
|
||||
Title: strings.TrimSpace(parts[0]),
|
||||
URL: strings.TrimSpace(parts[1]),
|
||||
}
|
||||
if len(parts) == 3 {
|
||||
fmt.Sscanf(strings.TrimSpace(parts[2]), "%d", &nav.Order)
|
||||
}
|
||||
m.navigation = append(m.navigation, nav)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(m.navigation, func(i, j int) bool {
|
||||
return m.navigation[i].Order < m.navigation[j].Order
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) parsePost(content *github.Content) (*Post, error) {
|
||||
lines := strings.Split(content.Content, "\n")
|
||||
|
||||
var frontmatter map[string]interface{}
|
||||
var contentStart int
|
||||
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
if i == 0 {
|
||||
frontmatter = m.parseFrontmatter(lines[1:])
|
||||
contentStart = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentLines := lines[contentStart:]
|
||||
for i, line := range contentLines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
contentLines = contentLines[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
post := &Post{
|
||||
Content: strings.Join(contentLines, "\n"),
|
||||
Path: content.Path,
|
||||
LastUpdated: content.LastUpdated,
|
||||
}
|
||||
|
||||
if title, ok := frontmatter["title"].(string); ok {
|
||||
post.Title = title
|
||||
post.Slug = m.generateSlug(title)
|
||||
}
|
||||
|
||||
if dateStr, ok := frontmatter["date"].(string); ok {
|
||||
if date, err := time.Parse("2006-01-02", dateStr); err == nil {
|
||||
post.Date = date
|
||||
}
|
||||
}
|
||||
|
||||
if categories, ok := frontmatter["categories"].([]interface{}); ok {
|
||||
for _, cat := range categories {
|
||||
if catStr, ok := cat.(string); ok {
|
||||
post.Categories = append(post.Categories, catStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tags, ok := frontmatter["tags"].([]interface{}); ok {
|
||||
for _, tag := range tags {
|
||||
if tagStr, ok := tag.(string); ok {
|
||||
post.Tags = append(post.Tags, tagStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post.HTML = string(blackfriday.Run([]byte(post.Content)))
|
||||
post.Excerpt = m.generateExcerpt(post.Content)
|
||||
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (m *Manager) parsePage(content *github.Content) (*Page, error) {
|
||||
lines := strings.Split(content.Content, "\n")
|
||||
|
||||
var frontmatter map[string]interface{}
|
||||
var contentStart int
|
||||
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
if i == 0 {
|
||||
frontmatter = m.parseFrontmatter(lines[1:])
|
||||
contentStart = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentLines := lines[contentStart:]
|
||||
for i, line := range contentLines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
contentLines = contentLines[i+1:]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
page := &Page{
|
||||
Content: strings.Join(contentLines, "\n"),
|
||||
Path: content.Path,
|
||||
}
|
||||
|
||||
if title, ok := frontmatter["title"].(string); ok {
|
||||
page.Title = title
|
||||
}
|
||||
|
||||
page.HTML = string(blackfriday.Run([]byte(page.Content)))
|
||||
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (m *Manager) parseFrontmatter(lines []string) map[string]interface{} {
|
||||
frontmatter := make(map[string]interface{})
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "---" {
|
||||
break
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") {
|
||||
value = strings.Trim(value, "[]")
|
||||
items := strings.Split(value, ",")
|
||||
var list []interface{}
|
||||
for _, item := range items {
|
||||
list = append(list, strings.TrimSpace(item))
|
||||
}
|
||||
frontmatter[key] = list
|
||||
} else {
|
||||
frontmatter[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return frontmatter
|
||||
}
|
||||
|
||||
func (m *Manager) generateSlug(title string) string {
|
||||
if title == "" {
|
||||
return "untitled"
|
||||
}
|
||||
slug := strings.ToLower(title)
|
||||
slug = regexp.MustCompile(`[^a-z0-9\s-]`).ReplaceAllString(slug, "")
|
||||
slug = regexp.MustCompile(`\s+`).ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if slug == "" {
|
||||
return "untitled"
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func (m *Manager) generateExcerpt(content string) string {
|
||||
words := strings.Fields(content)
|
||||
if len(words) <= 30 {
|
||||
return content
|
||||
}
|
||||
return strings.Join(words[:30], " ") + "..."
|
||||
}
|
||||
|
||||
func (m *Manager) GetPost(slug string) (*Post, bool) {
|
||||
post, exists := m.posts[slug]
|
||||
return post, exists
|
||||
}
|
||||
|
||||
func (m *Manager) GetAllPosts() []*Post {
|
||||
var posts []*Post
|
||||
for _, post := range m.posts {
|
||||
posts = append(posts, post)
|
||||
}
|
||||
|
||||
sort.Slice(posts, func(i, j int) bool {
|
||||
return posts[i].Date.After(posts[j].Date)
|
||||
})
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
func (m *Manager) GetPostsByCategory(category string) []*Post {
|
||||
var posts []*Post
|
||||
for _, post := range m.posts {
|
||||
for _, cat := range post.Categories {
|
||||
if cat == category {
|
||||
posts = append(posts, post)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(posts, func(i, j int) bool {
|
||||
return posts[i].Date.After(posts[j].Date)
|
||||
})
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
func (m *Manager) GetPage(path string) (*Page, bool) {
|
||||
page, exists := m.pages[path]
|
||||
return page, exists
|
||||
}
|
||||
|
||||
func (m *Manager) GetNavigation() []Navigation {
|
||||
return m.navigation
|
||||
}
|
||||
|
||||
func (m *Manager) GetLastUpdate() time.Time {
|
||||
return m.lastUpdate
|
||||
}
|
110
internal/content/manager_test.go
Normal file
110
internal/content/manager_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateSlug(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"Hello World", "hello-world"},
|
||||
{"Hello, World!", "hello-world"},
|
||||
{"Test@#$%^&*()", "test"},
|
||||
{" Multiple Spaces ", "multiple-spaces"},
|
||||
{"", "untitled"},
|
||||
{"!@#$%", "untitled"},
|
||||
{"123 Test", "123-test"},
|
||||
{"Test-With-Dashes", "test-with-dashes"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := m.generateSlug(test.input)
|
||||
if result != test.expected {
|
||||
t.Errorf("generateSlug(%q) = %q, expected %q", test.input, result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateExcerpt(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"This is a short text",
|
||||
"This is a short text",
|
||||
},
|
||||
{
|
||||
"One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven twenty-eight twenty-nine thirty thirty-one",
|
||||
"One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty twenty-one twenty-two twenty-three twenty-four twenty-five twenty-six twenty-seven twenty-eight twenty-nine thirty...",
|
||||
},
|
||||
{
|
||||
"",
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
result := m.generateExcerpt(test.input)
|
||||
if result != test.expected {
|
||||
t.Errorf("generateExcerpt() = %q, expected %q", result, test.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lines []string
|
||||
expected map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "simple frontmatter",
|
||||
lines: []string{
|
||||
"title: My Post",
|
||||
"date: 2024-01-15",
|
||||
"---",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "My Post",
|
||||
"date": "2024-01-15",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "frontmatter with arrays",
|
||||
lines: []string{
|
||||
"title: My Post",
|
||||
"categories: [Technology, Go]",
|
||||
"tags: [golang, web]",
|
||||
"---",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"title": "My Post",
|
||||
"categories": []interface{}{"Technology", "Go"},
|
||||
"tags": []interface{}{"golang", "web"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty frontmatter",
|
||||
lines: []string{},
|
||||
expected: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := m.parseFrontmatter(test.lines)
|
||||
if len(result) != len(test.expected) {
|
||||
t.Errorf("parseFrontmatter() returned %d items, expected %d", len(result), len(test.expected))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
98
internal/github/client.go
Normal file
98
internal/github/client.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-github/v56/github"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
client *github.Client
|
||||
owner string
|
||||
repo string
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
Path string
|
||||
Content string
|
||||
SHA string
|
||||
LastUpdated time.Time
|
||||
}
|
||||
|
||||
func NewClient(token, owner, repo string) *Client {
|
||||
ctx := context.Background()
|
||||
ts := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: token},
|
||||
)
|
||||
tc := oauth2.NewClient(ctx, ts)
|
||||
client := github.NewClient(tc)
|
||||
|
||||
return &Client{
|
||||
client: client,
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetFileContent(path string) (*Content, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
fileContent, _, _, err := c.client.Repositories.GetContents(ctx, c.owner, c.repo, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get file content: %w", err)
|
||||
}
|
||||
|
||||
content, err := fileContent.GetContent()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode content: %w", err)
|
||||
}
|
||||
|
||||
return &Content{
|
||||
Path: path,
|
||||
Content: content,
|
||||
SHA: fileContent.GetSHA(),
|
||||
LastUpdated: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListDirectory(path string) ([]*Content, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, contents, _, err := c.client.Repositories.GetContents(ctx, c.owner, c.repo, path, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list directory: %w", err)
|
||||
}
|
||||
|
||||
var result []*Content
|
||||
for _, content := range contents {
|
||||
if content.GetType() == "file" {
|
||||
fileContent, err := c.GetFileContent(content.GetPath())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
result = append(result, fileContent)
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetLastCommit() (*github.RepositoryCommit, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
commits, _, err := c.client.Repositories.ListCommits(ctx, c.owner, c.repo, &github.CommitsListOptions{
|
||||
ListOptions: github.ListOptions{PerPage: 1},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get last commit: %w", err)
|
||||
}
|
||||
|
||||
if len(commits) == 0 {
|
||||
return nil, fmt.Errorf("no commits found")
|
||||
}
|
||||
|
||||
return commits[0], nil
|
||||
}
|
308
internal/server/server.go
Normal file
308
internal/server/server.go
Normal file
@@ -0,0 +1,308 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitblog/internal/cache"
|
||||
"gitblog/internal/config"
|
||||
"gitblog/internal/content"
|
||||
"gitblog/internal/github"
|
||||
"gitblog/internal/templates"
|
||||
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
githubClient *github.Client
|
||||
contentMgr *content.Manager
|
||||
cacheMgr *cache.Manager
|
||||
templateMgr *templates.Manager
|
||||
updateTicker *time.Ticker
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Server {
|
||||
githubClient := github.NewClient(cfg.GitHubToken, cfg.GitHubOwner, cfg.GitHubRepo)
|
||||
contentMgr := content.NewManager(githubClient)
|
||||
cacheMgr := cache.NewManager(cfg.CacheDuration, 5*time.Minute)
|
||||
templateMgr := templates.NewManager()
|
||||
|
||||
return &Server{
|
||||
config: cfg,
|
||||
githubClient: githubClient,
|
||||
contentMgr: contentMgr,
|
||||
cacheMgr: cacheMgr,
|
||||
templateMgr: templateMgr,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start() error {
|
||||
if err := s.templateMgr.LoadTemplates(); err != nil {
|
||||
return fmt.Errorf("failed to load templates: %w", err)
|
||||
}
|
||||
|
||||
if err := s.contentMgr.LoadContent(); err != nil {
|
||||
return fmt.Errorf("failed to load content: %w", err)
|
||||
}
|
||||
|
||||
s.startContentUpdater()
|
||||
|
||||
router := s.setupRoutes()
|
||||
|
||||
corsHandler := handlers.CORS(
|
||||
handlers.AllowedOrigins([]string{"*"}),
|
||||
handlers.AllowedMethods([]string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}),
|
||||
handlers.AllowedHeaders([]string{"X-Requested-With", "Content-Type", "Authorization"}),
|
||||
)(router)
|
||||
|
||||
loggedRouter := handlers.LoggingHandler(log.Writer(), corsHandler)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + s.config.Port,
|
||||
Handler: loggedRouter,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
return server.ListenAndServe()
|
||||
}
|
||||
|
||||
func (s *Server) setupRoutes() *mux.Router {
|
||||
router := mux.NewRouter()
|
||||
|
||||
router.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("web/static/"))))
|
||||
|
||||
router.HandleFunc("/", s.handleHome).Methods("GET")
|
||||
router.HandleFunc("/post/{slug}", s.handlePost).Methods("GET")
|
||||
router.HandleFunc("/category/{category}", s.handleCategory).Methods("GET")
|
||||
router.HandleFunc("/page/{path:.*}", s.handlePage).Methods("GET")
|
||||
router.HandleFunc("/rss.xml", s.handleRSS).Methods("GET")
|
||||
router.HandleFunc("/api/update", s.handleUpdate).Methods("POST")
|
||||
|
||||
router.NotFoundHandler = http.HandlerFunc(s.handle404)
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||
theme := s.getTheme(r)
|
||||
|
||||
posts, found := s.cacheMgr.GetPosts()
|
||||
if !found {
|
||||
posts = s.contentMgr.GetAllPosts()
|
||||
s.cacheMgr.SetPosts(posts, s.config.CacheDuration)
|
||||
}
|
||||
|
||||
data := templates.PageData{
|
||||
Title: "Home",
|
||||
Posts: posts,
|
||||
Navigation: s.contentMgr.GetNavigation(),
|
||||
Theme: theme,
|
||||
CurrentYear: time.Now().Year(),
|
||||
}
|
||||
|
||||
html, err := s.templateMgr.Render("home", data)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (s *Server) handlePost(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
slug := vars["slug"]
|
||||
theme := s.getTheme(r)
|
||||
|
||||
post, found := s.cacheMgr.GetPost(slug)
|
||||
if !found {
|
||||
post, found = s.contentMgr.GetPost(slug)
|
||||
if !found {
|
||||
s.handle404(w, r)
|
||||
return
|
||||
}
|
||||
s.cacheMgr.SetPost(slug, post, s.config.CacheDuration)
|
||||
}
|
||||
|
||||
data := templates.PageData{
|
||||
Title: post.Title,
|
||||
Post: post,
|
||||
Navigation: s.contentMgr.GetNavigation(),
|
||||
Theme: theme,
|
||||
CurrentYear: time.Now().Year(),
|
||||
Meta: map[string]string{
|
||||
"description": post.Excerpt,
|
||||
},
|
||||
}
|
||||
|
||||
html, err := s.templateMgr.Render("post", data)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (s *Server) handleCategory(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
category := vars["category"]
|
||||
theme := s.getTheme(r)
|
||||
|
||||
posts, found := s.cacheMgr.GetPostsByCategory(category)
|
||||
if !found {
|
||||
posts = s.contentMgr.GetPostsByCategory(category)
|
||||
s.cacheMgr.SetPostsByCategory(category, posts, s.config.CacheDuration)
|
||||
}
|
||||
|
||||
data := templates.PageData{
|
||||
Title: strings.Title(category),
|
||||
Posts: posts,
|
||||
Navigation: s.contentMgr.GetNavigation(),
|
||||
Theme: theme,
|
||||
CurrentYear: time.Now().Year(),
|
||||
}
|
||||
|
||||
html, err := s.templateMgr.Render("category", data)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
path := vars["path"]
|
||||
theme := s.getTheme(r)
|
||||
|
||||
page, found := s.cacheMgr.GetPage(path)
|
||||
if !found {
|
||||
page, found = s.contentMgr.GetPage(path)
|
||||
if !found {
|
||||
s.handle404(w, r)
|
||||
return
|
||||
}
|
||||
s.cacheMgr.SetPage(path, page, s.config.CacheDuration)
|
||||
}
|
||||
|
||||
data := templates.PageData{
|
||||
Title: page.Title,
|
||||
Page: page,
|
||||
Navigation: s.contentMgr.GetNavigation(),
|
||||
Theme: theme,
|
||||
CurrentYear: time.Now().Year(),
|
||||
}
|
||||
|
||||
html, err := s.templateMgr.Render("page", data)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
|
||||
posts := s.contentMgr.GetAllPosts()
|
||||
|
||||
rss := `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>GitBlog</title>
|
||||
<description>A modern blog powered by GitHub</description>
|
||||
<link>` + s.config.BaseURL + `</link>
|
||||
<lastBuildDate>` + time.Now().Format(time.RFC1123Z) + `</lastBuildDate>
|
||||
`
|
||||
|
||||
for _, post := range posts {
|
||||
rss += ` <item>
|
||||
<title>` + post.Title + `</title>
|
||||
<description><![CDATA[` + post.Excerpt + `]]></description>
|
||||
<link>` + s.config.BaseURL + `/post/` + post.Slug + `</link>
|
||||
<pubDate>` + post.Date.Format(time.RFC1123Z) + `</pubDate>
|
||||
</item>
|
||||
`
|
||||
}
|
||||
|
||||
rss += `</channel>
|
||||
</rss>`
|
||||
|
||||
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
|
||||
w.Write([]byte(rss))
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.contentMgr.LoadContent(); err != nil {
|
||||
http.Error(w, "Failed to update content", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.cacheMgr.Clear()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Content updated successfully"))
|
||||
}
|
||||
|
||||
func (s *Server) handle404(w http.ResponseWriter, r *http.Request) {
|
||||
theme := s.getTheme(r)
|
||||
|
||||
data := templates.PageData{
|
||||
Title: "404 - Page Not Found",
|
||||
Navigation: s.contentMgr.GetNavigation(),
|
||||
Theme: theme,
|
||||
CurrentYear: time.Now().Year(),
|
||||
}
|
||||
|
||||
html, err := s.templateMgr.Render("404", data)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(html))
|
||||
}
|
||||
|
||||
func (s *Server) getTheme(r *http.Request) string {
|
||||
cookie, err := r.Cookie("theme")
|
||||
if err != nil {
|
||||
return s.config.Theme
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func (s *Server) startContentUpdater() {
|
||||
s.updateTicker = time.NewTicker(s.config.UpdateInterval)
|
||||
|
||||
go func() {
|
||||
for range s.updateTicker.C {
|
||||
lastCommit, err := s.githubClient.GetLastCommit()
|
||||
if err != nil {
|
||||
log.Printf("Failed to check for updates: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if lastCommit.Commit.Committer.Date.After(s.contentMgr.GetLastUpdate()) {
|
||||
log.Println("Content update detected, reloading...")
|
||||
if err := s.contentMgr.LoadContent(); err != nil {
|
||||
log.Printf("Failed to reload content: %v", err)
|
||||
continue
|
||||
}
|
||||
s.cacheMgr.Clear()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
294
internal/templates/manager.go
Normal file
294
internal/templates/manager.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitblog/internal/content"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
templates map[string]*template.Template
|
||||
funcMap template.FuncMap
|
||||
}
|
||||
|
||||
type PageData struct {
|
||||
Title string
|
||||
Content template.HTML
|
||||
Posts []*content.Post
|
||||
Post *content.Post
|
||||
Page *content.Page
|
||||
Navigation []content.Navigation
|
||||
Categories []string
|
||||
Theme string
|
||||
CurrentYear int
|
||||
Meta map[string]string
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
funcMap := template.FuncMap{
|
||||
"formatDate": formatDate,
|
||||
"formatDateTime": formatDateTime,
|
||||
"truncate": truncate,
|
||||
"safeHTML": safeHTML,
|
||||
"join": strings.Join,
|
||||
"contains": strings.Contains,
|
||||
"hasPrefix": strings.HasPrefix,
|
||||
"hasSuffix": strings.HasSuffix,
|
||||
"toLower": strings.ToLower,
|
||||
"toUpper": strings.ToUpper,
|
||||
"title": strings.Title,
|
||||
"now": time.Now,
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
templates: make(map[string]*template.Template),
|
||||
funcMap: funcMap,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) LoadTemplates() error {
|
||||
templates := []string{
|
||||
"base",
|
||||
"home",
|
||||
"post",
|
||||
"page",
|
||||
"category",
|
||||
"404",
|
||||
}
|
||||
|
||||
for _, name := range templates {
|
||||
if err := m.loadTemplate(name); err != nil {
|
||||
return fmt.Errorf("failed to load template %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) loadTemplate(name string) error {
|
||||
baseTemplate := m.getBaseTemplate()
|
||||
pageTemplate := m.getPageTemplate(name)
|
||||
|
||||
tmpl, err := template.New(name).Funcs(m.funcMap).Parse(baseTemplate + pageTemplate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.templates[name] = tmpl
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) Render(templateName string, data PageData) (string, error) {
|
||||
tmpl, exists := m.templates[templateName]
|
||||
if !exists {
|
||||
return "", fmt.Errorf("template %s not found", templateName)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (m *Manager) getBaseTemplate() string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en" data-theme="{{.Theme}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{if .Title}}{{.Title}} - {{end}}GitBlog</title>
|
||||
<meta name="description" content="{{if .Meta.description}}{{.Meta.description}}{{else}}A modern blog powered by GitHub{{end}}">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<nav class="nav">
|
||||
<div class="nav-brand">
|
||||
<a href="/" class="nav-logo">GitBlog</a>
|
||||
</div>
|
||||
<div class="nav-menu">
|
||||
{{range .Navigation}}
|
||||
<a href="{{.URL}}" class="nav-link">{{.Title}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
|
||||
<span class="theme-icon">🌙</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="footer-content">
|
||||
<p>© {{.CurrentYear}} GitBlog. Powered by GitHub.</p>
|
||||
<div class="footer-links">
|
||||
<a href="https://github.com" target="_blank" rel="noopener">GitHub</a>
|
||||
<a href="/rss.xml" target="_blank" rel="noopener">RSS</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/theme.js"></script>
|
||||
<script src="/static/js/main.js"></script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
func (m *Manager) getPageTemplate(name string) string {
|
||||
templates := map[string]string{
|
||||
"home": `{{define "content"}}
|
||||
<div class="hero">
|
||||
<h1 class="hero-title">Welcome to GitBlog</h1>
|
||||
<p class="hero-subtitle">A modern blog powered by GitHub</p>
|
||||
</div>
|
||||
|
||||
<div class="posts-grid">
|
||||
{{range .Posts}}
|
||||
<article class="post-card">
|
||||
<div class="post-meta">
|
||||
<time class="post-date">{{.Date | formatDate}}</time>
|
||||
{{if .Categories}}
|
||||
<span class="post-categories">
|
||||
{{range .Categories}}
|
||||
<span class="category-tag">{{.}}</span>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
</h2>
|
||||
<div class="post-excerpt">{{.Excerpt | safeHTML}}</div>
|
||||
<div class="post-tags">
|
||||
{{range .Tags}}
|
||||
<span class="tag">{{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}`,
|
||||
|
||||
"post": `{{define "content"}}
|
||||
<article class="post">
|
||||
<header class="post-header">
|
||||
<h1 class="post-title">{{.Post.Title}}</h1>
|
||||
<div class="post-meta">
|
||||
<time class="post-date">{{.Post.Date | formatDateTime}}</time>
|
||||
{{if .Post.Categories}}
|
||||
<div class="post-categories">
|
||||
{{range .Post.Categories}}
|
||||
<a href="/category/{{. | toLower}}" class="category-link">{{.}}</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="post-content">
|
||||
{{.Post.HTML | safeHTML}}
|
||||
</div>
|
||||
|
||||
{{if .Post.Tags}}
|
||||
<footer class="post-footer">
|
||||
<div class="post-tags">
|
||||
{{range .Post.Tags}}
|
||||
<span class="tag">{{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</footer>
|
||||
{{end}}
|
||||
</article>
|
||||
{{end}}`,
|
||||
|
||||
"page": `{{define "content"}}
|
||||
<article class="page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">{{.Page.Title}}</h1>
|
||||
</header>
|
||||
|
||||
<div class="page-content">
|
||||
{{.Page.HTML | safeHTML}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}`,
|
||||
|
||||
"category": `{{define "content"}}
|
||||
<div class="category-header">
|
||||
<h1 class="category-title">Category: {{.Title}}</h1>
|
||||
<p class="category-description">Posts in this category</p>
|
||||
</div>
|
||||
|
||||
<div class="posts-grid">
|
||||
{{range .Posts}}
|
||||
<article class="post-card">
|
||||
<div class="post-meta">
|
||||
<time class="post-date">{{.Date | formatDate}}</time>
|
||||
{{if .Categories}}
|
||||
<span class="post-categories">
|
||||
{{range .Categories}}
|
||||
<span class="category-tag">{{.}}</span>
|
||||
{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<h2 class="post-title">
|
||||
<a href="/post/{{.Slug}}">{{.Title}}</a>
|
||||
</h2>
|
||||
<div class="post-excerpt">{{.Excerpt | safeHTML}}</div>
|
||||
<div class="post-tags">
|
||||
{{range .Tags}}
|
||||
<span class="tag">{{.}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}`,
|
||||
|
||||
"404": `{{define "content"}}
|
||||
<div class="error-page">
|
||||
<h1 class="error-title">404</h1>
|
||||
<p class="error-message">Page not found</p>
|
||||
<a href="/" class="error-link">Go back home</a>
|
||||
</div>
|
||||
{{end}}`,
|
||||
}
|
||||
|
||||
return templates[name]
|
||||
}
|
||||
|
||||
func formatDate(t time.Time) string {
|
||||
return t.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
func formatDateTime(t time.Time) string {
|
||||
return t.Format("January 2, 2006 at 3:04 PM")
|
||||
}
|
||||
|
||||
func truncate(s string, length int) string {
|
||||
if len(s) <= length {
|
||||
return s
|
||||
}
|
||||
return s[:length] + "..."
|
||||
}
|
||||
|
||||
func safeHTML(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
}
|
Reference in New Issue
Block a user