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

97
.gitignore vendored Normal file
View File

@@ -0,0 +1,97 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
gitblog
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
# Environment variables
.env
.env.local
.env.production
# IDE files
.vscode/
.idea/
*.swp
*.swo
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o gitblog main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /root/
COPY --from=builder /app/gitblog .
COPY --from=builder /app/web ./web
EXPOSE 8080
CMD ["./gitblog"]

34
Makefile Normal file
View File

@@ -0,0 +1,34 @@
.PHONY: build run clean test deps
build:
go build -o gitblog main.go
run: build
./gitblog
clean:
rm -f gitblog
test:
go test ./...
deps:
go mod tidy
go mod download
dev:
go run main.go
install:
go install .
help:
@echo "Available commands:"
@echo " build - Build the application"
@echo " run - Build and run the application"
@echo " clean - Remove build artifacts"
@echo " test - Run tests"
@echo " deps - Download dependencies"
@echo " dev - Run in development mode"
@echo " install - Install the application"
@echo " help - Show this help message"

266
README.md Normal file
View File

@@ -0,0 +1,266 @@
# GitBlog
A blog system that's actually not terrible. Built with Go because why the hell not, and uses GitHub as a headless CMS because apparently we're too lazy to build a proper admin panel. All your content lives as Markdown files in a GitHub repository, and GitBlog dynamically fetches and renders it like some kind of digital wizard.
## Features (The Good Stuff)
- **GitHub as CMS**: Store all your content (posts, pages, navigation) as Markdown files in your repository because who needs a fancy dashboard anyway
- **Dynamic Content**: Automatically fetches and renders content via GitHub API - it's like magic, but with more HTTP requests
- **Responsive Design**: Looks decent on mobile devices because apparently people use phones now
- **Dark/Light Mode**: Toggle between themes because your eyes deserve better than staring at white screens all day
- **Performance Optimized**: Built-in caching system so your blog doesn't load like it's running on a potato
- **Auto-Updates**: Automatically detects and loads new content when repository changes - no more manual refreshing like some kind of caveman
- **Clean URLs**: SEO-friendly routing because Google needs to find your ramblings somehow
- **RSS Feed**: Automatic RSS feed generation for the three people who still use RSS readers
- **Metadata Support**: Full support for post dates, categories, tags, and excerpts because organization is apparently important
- **Customizable Layouts**: Flexible templating system for when you want to make it look less like a default WordPress theme
## Quick Start (Don't Screw This Up)
### Prerequisites
- Go 1.21 or later (because we're not living in the stone age)
- A GitHub repository with your blog content (obviously)
- A GitHub personal access token (because security is a thing)
### Installation
1. **Clone the repository**
```bash
git clone <your-repo-url>
cd gitblog
```
2. **Install dependencies**
```bash
go mod tidy
```
3. **Set up environment variables**
```bash
cp env.example .env
# Edit .env with your GitHub credentials (don't commit this file, you'll regret it)
```
4. **Configure your GitHub repository**
Create the following structure in your GitHub repository (or don't, see what happens):
```
content/
├── posts/
│ ├── my-first-post.md
│ └── another-post.md
├── pages/
│ ├── about.md
│ └── contact.md
└── navigation.md
```
5. **Run the application**
```bash
go run main.go
```
6. **Visit your blog**
Open http://localhost:8080 in your browser and pray it works
## Content Structure (How to Not Break Things)
### Posts
Create blog posts in the `content/posts/` directory. Each post should have frontmatter (it's like metadata, but fancier):
```markdown
---
title: "My Amazing Post"
date: 2024-01-15
categories: ["Technology", "Go"]
tags: ["golang", "web", "blog"]
---
Your post content goes here. You can use full Markdown syntax including:
- **Bold text** (for emphasis)
- *Italic text* (for when you're feeling fancy)
- [Links](https://example.com) (because the internet is a thing)
- Code blocks (for when you want to look smart)
- And much more!
```
### Pages
Create static pages in the `content/pages/` directory (because sometimes you need an about page):
```markdown
---
title: "About Me"
---
This is a static page about you or your blog. Try not to make it too cringe.
```
### Navigation
Configure your site navigation in `content/navigation.md` (because menus are apparently important):
```markdown
Home|/
About|/page/about
Contact|/page/contact
Blog|/|1
```
## Configuration (The Boring Stuff)
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `GITHUB_TOKEN` | Your GitHub personal access token | Required (duh) |
| `GITHUB_OWNER` | GitHub username or organization | Required (also duh) |
| `GITHUB_REPO` | Repository name | Required (triple duh) |
| `PORT` | Server port | 8080 |
| `DEFAULT_THEME` | Default theme (light/dark) | light |
| `BASE_URL` | Base URL for your blog | http://localhost:8080 |
| `CACHE_DURATION` | How long to cache content | 15m |
| `UPDATE_INTERVAL` | How often to check for updates | 5m |
### GitHub Token Setup
1. Go to GitHub Settings > Developer settings > Personal access tokens
2. Generate a new token with `repo` scope (because we need to read your stuff)
3. Add the token to your `.env` file (and for the love of god, don't commit it)
## Customization (Make It Yours)
### Themes
The application supports both light and dark themes. Users can toggle between them, and their preference is saved locally (because we're not monsters).
### Styling
Modify `web/static/css/style.css` to customize the appearance. The CSS uses CSS custom properties (variables) for easy theming because we're not savages.
### Templates
Templates are defined in Go code in `internal/templates/manager.go`. You can modify the HTML structure and add new template functions as needed (if you're brave enough).
## Architecture (The Technical Stuff)
```
gitblog/
├── internal/
│ ├── cache/ # Caching system (because performance matters)
│ ├── config/ # Configuration management (because settings are hard)
│ ├── content/ # Content parsing and management (the meat and potatoes)
│ ├── github/ # GitHub API client (because we need to talk to GitHub)
│ ├── server/ # HTTP server and routing (because HTTP is a thing)
│ └── templates/ # HTML templating (because HTML is still a thing)
├── web/
│ └── static/ # Static assets (CSS, JS)
├── main.go # Application entry point (where the magic begins)
└── go.mod # Go module definition (because dependencies are a thing)
```
### Key Components
- **GitHub Client**: Fetches content from GitHub API (because we're not storing it locally)
- **Content Manager**: Parses Markdown and manages content structure (because Markdown is awesome)
- **Cache Manager**: Provides in-memory caching for performance (because nobody likes slow websites)
- **Template Manager**: Handles HTML rendering with Go templates (because we're not writing HTML by hand)
- **Server**: HTTP server with routing and middleware (because we need to serve something)
## Deployment (Time to Go Live)
### Using Docker (The Easy Way)
1. The Dockerfile is already there (because we're not animals):
```dockerfile
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o gitblog main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/gitblog .
COPY --from=builder /app/web ./web
CMD ["./gitblog"]
```
2. Build and run:
```bash
docker build -t gitblog .
docker run -p 8080:8080 --env-file .env gitblog
```
### Using systemd (Linux - The Hard Way)
1. Create a service file at `/etc/systemd/system/gitblog.service`:
```ini
[Unit]
Description=GitBlog Service
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/path/to/gitblog
ExecStart=/path/to/gitblog/gitblog
EnvironmentFile=/path/to/gitblog/.env
Restart=always
[Install]
WantedBy=multi-user.target
```
2. Enable and start the service:
```bash
sudo systemctl enable gitblog
sudo systemctl start gitblog
```
## Development (For the Brave)
### Running in Development
```bash
# Install air for hot reloading (because manually restarting is for peasants)
go install github.com/cosmtrek/air@latest
# Run with hot reloading
air
```
### Adding New Features
1. **New Content Types**: Extend the content manager to support new content types (because variety is the spice of life)
2. **New Templates**: Add new template functions or modify existing ones (because customization is key)
3. **API Endpoints**: Add new routes in the server package (because APIs are cool)
4. **Caching**: Extend the cache manager for new data types (because performance is everything)
## Performance (Because Speed Matters)
- **Caching**: In-memory caching reduces GitHub API calls (because API limits are a thing)
- **Concurrent Processing**: Content loading and rendering is optimized for performance (because we're not running on a single core)
- **Static Assets**: CSS and JavaScript are served efficiently (because bandwidth costs money)
- **Responsive Images**: Lazy loading for better performance (because nobody wants to wait for images)
## License
This project is open source and available under the [MIT License](LICENSE) (because we're not greedy).
## Acknowledgments (The People Who Made This Possible)
- Built with [Go](https://golang.org/) (because it's fast and not terrible)
- Uses [GitHub API](https://docs.github.com/en/rest) (because we're lazy)
- Markdown parsing with [Blackfriday](https://github.com/russross/blackfriday) (because Markdown is awesome)
- HTTP routing with [Gorilla Mux](https://github.com/gorilla/mux) (because routing is hard)
- Caching with [go-cache](https://github.com/patrickmn/go-cache) (because performance matters)
---
**Now go write some damn good content!**

18
docker-compose.yml Normal file
View File

@@ -0,0 +1,18 @@
version: '3.8'
services:
gitblog:
build: .
ports:
- "8080:8080"
environment:
- GITHUB_TOKEN=${GITHUB_TOKEN}
- GITHUB_OWNER=${GITHUB_OWNER}
- GITHUB_REPO=${GITHUB_REPO}
- PORT=8080
- DEFAULT_THEME=light
- CACHE_DURATION=15m
- UPDATE_INTERVAL=5m
volumes:
- ./web:/root/web
restart: unless-stopped

13
env.example Normal file
View File

@@ -0,0 +1,13 @@
# GitHub Configuration
GITHUB_TOKEN=your_github_token_here
GITHUB_OWNER=your_username
GITHUB_REPO=your_blog_repo
# Server Configuration
PORT=8080
DEFAULT_THEME=light
BASE_URL=http://localhost:8080
# Cache Configuration
CACHE_DURATION=15m
UPDATE_INTERVAL=5m

21
go.mod Normal file
View File

@@ -0,0 +1,21 @@
module gitblog
go 1.21
require (
github.com/google/go-github/v56 v56.0.0
github.com/gorilla/handlers v1.5.2
github.com/gorilla/mux v1.8.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/russross/blackfriday/v2 v2.1.0
golang.org/x/oauth2 v0.15.0
)
require (
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/go-querystring v1.1.0 // indirect
golang.org/x/net v0.19.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

39
go.sum Normal file
View File

@@ -0,0 +1,39 @@
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v56 v56.0.0 h1:TysL7dMa/r7wsQi44BjqlwaHvwlFlqkK8CtBWCX3gb4=
github.com/google/go-github/v56 v56.0.0/go.mod h1:D8cdcX98YWJvi7TLo7zM4/h8ZTx6u6fwGEkCdisopo0=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=

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

22
main.go Normal file
View File

@@ -0,0 +1,22 @@
package main
import (
"log"
"gitblog/internal/config"
"gitblog/internal/server"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %v", err)
}
srv := server.New(cfg)
log.Printf("Starting server on port %s", cfg.Port)
if err := srv.Start(); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}

View File

@@ -0,0 +1,4 @@
Home|/
About|/page/about
Posts|/|1
Contact|/page/contact

View File

@@ -0,0 +1,50 @@
---
title: "About"
---
# About GitBlog
GitBlog is a modern, responsive blog system built with Go that uses GitHub as a headless CMS. It's designed for developers who want a simple, powerful way to manage their blog content.
## Philosophy
We believe that content management should be:
- **Simple**: Write in Markdown, store in GitHub
- **Fast**: Optimized for performance and speed
- **Flexible**: Customizable themes and layouts
- **Reliable**: Built on proven technologies
## Technology Stack
- **Backend**: Go with Gorilla Mux for routing
- **Frontend**: Vanilla HTML, CSS, and JavaScript
- **Content**: Markdown with GitHub API
- **Caching**: In-memory caching for performance
- **Templating**: Go's built-in template engine
## Features
- Dynamic content loading from GitHub
- Responsive design that works on all devices
- Dark and light mode support
- Automatic content updates
- RSS feed generation
- SEO-friendly URLs
- Fast page loads with caching
## Getting Started
Ready to start your own blog? Check out our [Quick Start Guide](/post/welcome-to-gitblog) to get up and running in minutes.
## Contributing
GitBlog is open source and welcomes contributions! Whether you're fixing bugs, adding features, or improving documentation, we'd love your help.
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
---
*Built with ❤️ using Go and GitHub*

View File

@@ -0,0 +1,76 @@
---
title: "Building a Blog with Go and GitHub"
date: 2024-01-20
categories: ["Technology", "Go"]
tags: ["golang", "web-development", "github-api", "tutorial"]
---
# Building a Modern Blog with Go and GitHub
In this post, we'll explore how to build a modern blog system using Go and GitHub as a headless CMS.
## Why Go for Web Development?
Go is an excellent choice for web development because of:
- **Performance**: Fast compilation and execution
- **Concurrency**: Built-in goroutines for handling multiple requests
- **Simplicity**: Clean, readable code
- **Standard Library**: Rich standard library for web development
## Architecture Overview
Our blog system consists of several key components:
1. **GitHub API Client**: Fetches content from GitHub
2. **Content Manager**: Parses Markdown and manages content
3. **Cache Manager**: Provides performance optimization
4. **Template Engine**: Renders HTML from templates
5. **HTTP Server**: Handles requests and routing
## Key Features
### Dynamic Content Loading
The system automatically fetches content from GitHub and caches it for performance:
```go
func (m *Manager) LoadContent() error {
if err := m.loadPosts(); err != nil {
return fmt.Errorf("failed to load posts: %w", err)
}
// ... load other content types
return nil
}
```
### Responsive Design
The frontend is built with modern CSS and JavaScript:
- CSS Grid and Flexbox for layouts
- CSS Custom Properties for theming
- Responsive design principles
- Dark/light mode support
### Performance Optimization
- In-memory caching reduces API calls
- Lazy loading for images
- Minified CSS and JavaScript
- Efficient template rendering
## Getting Started
To get started with your own GitBlog instance:
1. Fork the repository
2. Set up your GitHub token
3. Configure your content structure
4. Deploy to your preferred platform
## Conclusion
Building a blog with Go and GitHub provides a powerful, flexible solution for content management. The combination of Go's performance and GitHub's content storage creates a robust platform for modern blogging.
Happy coding! 🚀

View File

@@ -0,0 +1,49 @@
---
title: "Welcome to GitBlog"
date: 2024-01-15
categories: ["Technology", "Blogging"]
tags: ["golang", "github", "blog", "cms"]
---
# Welcome to GitBlog!
This is your first blog post using GitBlog, a modern blog system that uses GitHub as a headless CMS.
## What makes GitBlog special?
- **GitHub as CMS**: All your content lives in a GitHub repository
- **Markdown Support**: Write posts in Markdown with full syntax support
- **Responsive Design**: Beautiful, mobile-first design
- **Dark/Light Mode**: Toggle between themes
- **Auto-Updates**: Content updates automatically when you push to GitHub
## Getting Started
1. Create a new Markdown file in your `content/posts/` directory
2. Add frontmatter with metadata
3. Write your content in Markdown
4. Push to GitHub
5. Your blog updates automatically!
## Code Example
Here's a simple Go code example:
```go
package main
import "fmt"
func main() {
fmt.Println("Hello, GitBlog!")
}
```
## Features
- **Categories**: Organize your posts by topic
- **Tags**: Add tags for better discoverability
- **Excerpts**: Automatic excerpt generation
- **RSS Feed**: Built-in RSS feed at `/rss.xml`
Happy blogging! 🎉

609
web/static/css/style.css Normal file
View File

@@ -0,0 +1,609 @@
:root {
--primary-color: #2563eb;
--primary-hover: #1d4ed8;
--secondary-color: #64748b;
--accent-color: #f59e0b;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--border-color: #e2e8f0;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--radius: 0.5rem;
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
[data-theme="dark"] {
--primary-color: #3b82f6;
--primary-hover: #2563eb;
--secondary-color: #94a3b8;
--accent-color: #fbbf24;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-muted: #64748b;
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--border-color: #475569;
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3), 0 4px 6px -4px rgb(0 0 0 / 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-family);
line-height: 1.6;
color: var(--text-primary);
background-color: var(--bg-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.header {
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(10px);
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
max-width: 1200px;
margin: 0 auto;
padding-left: 1rem;
padding-right: 1rem;
}
.nav-brand {
font-size: 1.5rem;
font-weight: 700;
}
.nav-logo {
text-decoration: none;
color: var(--primary-color);
transition: color 0.3s ease;
}
.nav-logo:hover {
color: var(--primary-hover);
}
.nav-menu {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-link {
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
transition: color 0.3s ease;
position: relative;
}
.nav-link:hover {
color: var(--primary-color);
}
.nav-link::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
width: 0;
height: 2px;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.nav-link:hover::after {
width: 100%;
}
.nav-actions {
display: flex;
align-items: center;
gap: 1rem;
}
.theme-toggle {
background: none;
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 0.5rem;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
}
.theme-toggle:hover {
background-color: var(--bg-secondary);
border-color: var(--primary-color);
}
.theme-icon {
font-size: 1.2rem;
transition: transform 0.3s ease;
}
.theme-toggle:hover .theme-icon {
transform: rotate(15deg);
}
.main {
min-height: calc(100vh - 200px);
padding: 2rem 0;
}
.hero {
text-align: center;
padding: 4rem 0;
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border-radius: var(--radius);
margin-bottom: 3rem;
}
.hero-title {
font-size: 3rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 1rem;
background: linear-gradient(135deg, var(--primary-color), var(--accent-color));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 1.25rem;
color: var(--text-secondary);
max-width: 600px;
margin: 0 auto;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.post-card {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius);
padding: 1.5rem;
transition: all 0.3s ease;
box-shadow: var(--shadow-sm);
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
border-color: var(--primary-color);
}
.post-meta {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--text-muted);
}
.post-date {
font-weight: 500;
}
.post-categories {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.category-tag {
background-color: var(--primary-color);
color: white;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 500;
}
.post-title {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1rem;
line-height: 1.3;
}
.post-title a {
text-decoration: none;
color: var(--text-primary);
transition: color 0.3s ease;
}
.post-title a:hover {
color: var(--primary-color);
}
.post-excerpt {
color: var(--text-secondary);
margin-bottom: 1rem;
line-height: 1.6;
}
.post-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tag {
background-color: var(--bg-secondary);
color: var(--text-secondary);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.75rem;
border: 1px solid var(--border-color);
transition: all 0.3s ease;
}
.tag:hover {
background-color: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.post {
max-width: 800px;
margin: 0 auto;
background-color: var(--bg-primary);
border-radius: var(--radius);
padding: 2rem;
box-shadow: var(--shadow-sm);
}
.post-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.post-header .post-title {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.post-header .post-meta {
font-size: 1rem;
}
.post-content {
font-size: 1.125rem;
line-height: 1.8;
color: var(--text-primary);
}
.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4,
.post-content h5,
.post-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
color: var(--text-primary);
font-weight: 600;
}
.post-content h1 { font-size: 2rem; }
.post-content h2 { font-size: 1.75rem; }
.post-content h3 { font-size: 1.5rem; }
.post-content h4 { font-size: 1.25rem; }
.post-content p {
margin-bottom: 1.5rem;
}
.post-content a {
color: var(--primary-color);
text-decoration: none;
border-bottom: 1px solid transparent;
transition: border-color 0.3s ease;
}
.post-content a:hover {
border-bottom-color: var(--primary-color);
}
.post-content code {
background-color: var(--bg-secondary);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
border: 1px solid var(--border-color);
}
.post-content pre {
background-color: var(--bg-secondary);
padding: 1rem;
border-radius: var(--radius);
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--border-color);
}
.post-content pre code {
background: none;
padding: 0;
border: none;
}
.post-content blockquote {
border-left: 4px solid var(--primary-color);
padding-left: 1rem;
margin: 1.5rem 0;
font-style: italic;
color: var(--text-secondary);
}
.post-content ul,
.post-content ol {
margin: 1.5rem 0;
padding-left: 2rem;
}
.post-content li {
margin-bottom: 0.5rem;
}
.post-footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.page {
max-width: 800px;
margin: 0 auto;
background-color: var(--bg-primary);
border-radius: var(--radius);
padding: 2rem;
box-shadow: var(--shadow-sm);
}
.page-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.page-title {
font-size: 2.5rem;
font-weight: 600;
color: var(--text-primary);
}
.page-content {
font-size: 1.125rem;
line-height: 1.8;
color: var(--text-primary);
}
.category-header {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
border-radius: var(--radius);
}
.category-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.category-description {
font-size: 1.25rem;
color: var(--text-secondary);
}
.category-link {
text-decoration: none;
color: var(--primary-color);
font-weight: 500;
transition: color 0.3s ease;
}
.category-link:hover {
color: var(--primary-hover);
}
.error-page {
text-align: center;
padding: 4rem 2rem;
max-width: 600px;
margin: 0 auto;
}
.error-title {
font-size: 6rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 1rem;
}
.error-message {
font-size: 1.5rem;
color: var(--text-secondary);
margin-bottom: 2rem;
}
.error-link {
display: inline-block;
background-color: var(--primary-color);
color: white;
padding: 1rem 2rem;
border-radius: var(--radius);
text-decoration: none;
font-weight: 500;
transition: background-color 0.3s ease;
}
.error-link:hover {
background-color: var(--primary-hover);
}
.footer {
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: 2rem 0;
margin-top: 4rem;
}
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.footer-content p {
color: var(--text-secondary);
margin: 0;
}
.footer-links {
display: flex;
gap: 2rem;
}
.footer-links a {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.3s ease;
}
.footer-links a:hover {
color: var(--primary-color);
}
@media (max-width: 768px) {
.nav {
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.nav-menu {
gap: 1rem;
}
.hero-title {
font-size: 2rem;
}
.hero-subtitle {
font-size: 1rem;
}
.posts-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.post-card {
padding: 1rem;
}
.post {
padding: 1rem;
}
.post-header .post-title {
font-size: 2rem;
}
.page {
padding: 1rem;
}
.page-title {
font-size: 2rem;
}
.category-title {
font-size: 2rem;
}
.footer-content {
flex-direction: column;
text-align: center;
}
.footer-links {
justify-content: center;
}
}
@media (max-width: 480px) {
.container {
padding: 0 0.5rem;
}
.hero {
padding: 2rem 1rem;
}
.hero-title {
font-size: 1.75rem;
}
.post-header .post-title {
font-size: 1.75rem;
}
.page-title {
font-size: 1.75rem;
}
.category-title {
font-size: 1.75rem;
}
}

131
web/static/js/main.js Normal file
View File

@@ -0,0 +1,131 @@
class BlogApp {
constructor() {
this.init();
}
init() {
this.setupSmoothScrolling();
this.setupLazyLoading();
this.setupSearch();
this.setupAnimations();
}
setupSmoothScrolling() {
const links = document.querySelectorAll('a[href^="#"]');
links.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const target = document.querySelector(link.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
}
setupLazyLoading() {
const images = document.querySelectorAll('img[data-src]');
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.remove('lazy');
observer.unobserve(img);
}
});
});
images.forEach(img => imageObserver.observe(img));
}
setupSearch() {
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.addEventListener('input', this.debounce((e) => {
this.performSearch(e.target.value);
}, 300));
}
}
setupAnimations() {
const observerOptions = {
threshold: 0.1,
rootMargin: '0px 0px -50px 0px'
};
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate-in');
}
});
}, observerOptions);
const animatedElements = document.querySelectorAll('.post-card, .post, .page');
animatedElements.forEach(el => {
el.classList.add('animate-ready');
observer.observe(el);
});
}
performSearch(query) {
if (query.length < 2) return;
const posts = document.querySelectorAll('.post-card');
posts.forEach(post => {
const title = post.querySelector('.post-title').textContent.toLowerCase();
const excerpt = post.querySelector('.post-excerpt').textContent.toLowerCase();
const tags = Array.from(post.querySelectorAll('.tag')).map(tag => tag.textContent.toLowerCase());
const matches = title.includes(query.toLowerCase()) ||
excerpt.includes(query.toLowerCase()) ||
tags.some(tag => tag.includes(query.toLowerCase()));
post.style.display = matches ? 'block' : 'none';
});
}
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
}
document.addEventListener('DOMContentLoaded', () => {
new BlogApp();
});
const style = document.createElement('style');
style.textContent = `
.animate-ready {
opacity: 0;
transform: translateY(20px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
.animate-in {
opacity: 1;
transform: translateY(0);
}
.lazy {
opacity: 0;
transition: opacity 0.3s ease;
}
.lazy.loaded {
opacity: 1;
}
`;
document.head.appendChild(style);

62
web/static/js/theme.js Normal file
View File

@@ -0,0 +1,62 @@
class ThemeManager {
constructor() {
this.theme = this.getStoredTheme() || this.getSystemTheme();
this.init();
}
init() {
this.applyTheme(this.theme);
this.bindEvents();
}
getStoredTheme() {
return localStorage.getItem('theme');
}
getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
this.updateThemeIcon(theme);
localStorage.setItem('theme', theme);
}
updateThemeIcon(theme) {
const themeIcon = document.querySelector('.theme-icon');
if (themeIcon) {
themeIcon.textContent = theme === 'dark' ? '☀️' : '🌙';
}
}
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light';
this.applyTheme(this.theme);
this.setCookie('theme', this.theme, 365);
}
setCookie(name, value, days) {
const expires = new Date();
expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000));
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
}
bindEvents() {
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => this.toggleTheme());
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!this.getStoredTheme()) {
this.theme = e.matches ? 'dark' : 'light';
this.applyTheme(this.theme);
}
});
}
}
document.addEventListener('DOMContentLoaded', () => {
new ThemeManager();
});