This commit is contained in:
Dev
2025-09-15 04:02:11 +03:00
commit fc86288f06
24 changed files with 2938 additions and 0 deletions

120
internal/cache/manager.go vendored Normal file
View 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
View 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
}

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

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

View 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>&copy; {{.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)
}