This commit is contained in:
root 2026-02-12 11:56:26 +00:00
commit 1c93ab8607
12 changed files with 700 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
vendor
*.svg
*.lock
.vscode
cache
content
blog

89
cache.go Normal file
View File

@ -0,0 +1,89 @@
// cache.go
package main
import (
"crypto/md5"
"encoding/hex"
"os"
"path/filepath"
"sync"
)
var (
memoryCache = make(map[string][]byte)
cacheMutex sync.RWMutex
cacheDir = "cache"
// TestMode disables caching when set to true
TestMode bool
)
// InitCache ensures the cache directory exists
func InitCache() {
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
os.Mkdir(cacheDir, 0755)
}
}
// ClearCache wipes the cache directory (used for Test Mode)
func ClearCache() {
os.RemoveAll(cacheDir)
InitCache()
}
// getCacheFilename generates the hashed filename preserving the extension
func getCacheFilename(key string) string {
hash := md5.Sum([]byte(key))
ext := filepath.Ext(key)
// Default to .html if no extension (e.g. for processed markdown)
if ext == "" || ext == ".md" {
ext = ".html"
}
return filepath.Join(cacheDir, hex.EncodeToString(hash[:])+ext)
}
// CheckCache looks for content in memory, then on disk.
func CheckCache(key string) ([]byte, bool) {
if TestMode {
return nil, false
}
cacheMutex.RLock()
// 1. Check Memory
if data, found := memoryCache[key]; found {
cacheMutex.RUnlock()
return data, true
}
cacheMutex.RUnlock()
// 2. Check Disk
filePath := getCacheFilename(key)
data, err := os.ReadFile(filePath)
if err == nil {
// Populate memory for next time
cacheMutex.Lock()
memoryCache[key] = data
cacheMutex.Unlock()
return data, true
}
return nil, false
}
// StoreCache saves content to memory and disk.
func StoreCache(key string, data []byte) error {
if TestMode {
return nil
}
cacheMutex.Lock()
defer cacheMutex.Unlock()
// 1. Save to memory
memoryCache[key] = data
// 2. Save to disk
filePath := getCacheFilename(key)
return os.WriteFile(filePath, data, 0644)
}

104
filesystem.go Normal file
View File

@ -0,0 +1,104 @@
// filesystem.go
package main
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// ContentFile represents a discoverable file in the content directory
type ContentFile struct {
OriginalPath string
RoutePath string
IsMarkdown bool
ModTime time.Time
}
// RouteMap is our lookup table: Route -> File Info
type RouteMap map[string]ContentFile
// Global list for the index generator
var AllContent []ContentFile
// ScanContent walks the content directory and builds the routing map.
func ScanContent(rootDir string) (RouteMap, error) {
routes := make(RouteMap)
var contentList []ContentFile
fmt.Println("--- Scanning Content ---")
err := filepath.WalkDir(rootDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip root and hidden files
if path == rootDir || strings.HasPrefix(d.Name(), ".") {
return nil
}
if !d.IsDir() {
info, err := d.Info()
if err != nil {
return err
}
relPath, _ := filepath.Rel(rootDir, path)
relPath = filepath.ToSlash(relPath)
isMd := strings.HasSuffix(strings.ToLower(d.Name()), ".md")
// Determine Route
var route string
if isMd {
route = "/" + strings.TrimSuffix(relPath, filepath.Ext(relPath))
if strings.ToLower(route) == "/index" {
route = "/"
}
} else {
route = "/" + relPath
}
// Normalize to lowercase for case-insensitive URLs
route = strings.ToLower(route)
file := ContentFile{
OriginalPath: path,
RoutePath: route,
IsMarkdown: isMd,
ModTime: info.ModTime(),
}
// Add to Lookup Map
routes[route] = file
// Add to List (for index generation)
contentList = append(contentList, file)
fmt.Printf("Mapped: %s -> %s\n", route, path)
}
return nil
})
if err != nil {
return nil, err
}
// Sort list by date
sort.Slice(contentList, func(i, j int) bool {
return contentList[i].ModTime.After(contentList[j].ModTime)
})
AllContent = contentList
return routes, nil
}
// ReadRaw reads the file from the filesystem.
func ReadRaw(path string) ([]byte, error) {
return os.ReadFile(path)
}

12
go.mod Normal file
View File

@ -0,0 +1,12 @@
module git.else-if.org/jess/blog
go 1.25.0
require github.com/yuin/goldmark v1.7.16 // direct
require go.abhg.dev/goldmark/frontmatter v0.3.0
require (
github.com/BurntSushi/toml v1.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

16
go.sum Normal file
View File

@ -0,0 +1,16 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.abhg.dev/goldmark/frontmatter v0.3.0 h1:ZOrMkeyyYzhlbenFNmOXyGFx1dFE8TgBWAgZfs9D5RA=
go.abhg.dev/goldmark/frontmatter v0.3.0/go.mod h1:W3KXvVveKKxU1FIFZ7fgFFQrlkcolnDcOVmu19cCO9U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

30
main.go Normal file
View File

@ -0,0 +1,30 @@
// main.go
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "service":
InstallService()
return
case "test":
fmt.Println("Starting in TEST MODE (Caching Disabled, Cache Cleared)")
TestMode = true
// Wipe the cache so we don't see old files
ClearCache()
StartServer()
return
default:
fmt.Printf("Unknown command: %s\nUsage: blog [service|test]\n", os.Args[1])
return
}
}
// Default behavior: Production Server
StartServer()
}

118
parse.go Normal file
View File

@ -0,0 +1,118 @@
// parse.go
package main
import (
"bytes"
"fmt"
"path/filepath"
"strings"
"git.else-if.org/jess/blog/templates"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"go.abhg.dev/goldmark/frontmatter"
)
// GetContent is the primary entry point for retrieving parsed content.
func GetContent(file ContentFile) ([]byte, error) {
// 1. Ask Cache
if data, found := CheckCache(file.OriginalPath); found {
return data, nil
}
// 2. Read Raw
raw, err := ReadRaw(file.OriginalPath)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
// If not markdown (e.g. css, images), return raw bytes immediately
if !file.IsMarkdown {
_ = StoreCache(file.OriginalPath, raw)
return raw, nil
}
// 3. Configure Goldmark
// We use the Extender pattern which is the standard way to add frontmatter support.
// We also enable Unsafe mode to allow raw HTML tags (like <site-headline>) to pass through
// so we can replace them later.
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
&frontmatter.Extender{},
),
goldmark.WithRendererOptions(
html.WithUnsafe(),
),
)
// 4. Parse Markdown
var buf bytes.Buffer
ctx := parser.NewContext()
if err := md.Convert(raw, &buf, parser.WithContext(ctx)); err != nil {
return nil, fmt.Errorf("failed to parse markdown: %w", err)
}
// 5. Extract Frontmatter
// We retrieve the metadata using the context after parsing
var meta templates.PageMetadata
d := frontmatter.Get(ctx)
if d != nil {
if err := d.Decode(&meta); err != nil {
// If decoding fails, we just proceed without metadata
fmt.Printf("Warning: failed to decode frontmatter for %s: %v\n", file.OriginalPath, err)
}
}
// If title is missing, try to grab the filename
if meta.Title == "" {
base := filepath.Base(file.OriginalPath)
meta.Title = strings.TrimSuffix(base, filepath.Ext(base))
}
// 6. Special Handling for Index (Dynamic Components)
htmlContent := buf.String()
if file.RoutePath == "/" {
// Generate the list of posts
var posts []templates.PostSnippet
for _, f := range AllContent {
if f.IsMarkdown && f.RoutePath != "/" {
name := filepath.Base(f.OriginalPath)
title := strings.TrimSuffix(name, filepath.Ext(name))
posts = append(posts, templates.PostSnippet{
Title: title,
URL: f.RoutePath,
Date: f.ModTime,
})
}
}
latestPostsHTML := templates.RenderLatestPosts(posts)
dirLink := templates.RenderDirectoryLink()
// Replace custom tags if they exist in the markdown
if strings.Contains(htmlContent, "<latest-posts>") {
htmlContent = strings.ReplaceAll(htmlContent, "<latest-posts>", latestPostsHTML)
} else {
// Fallback: Append if not present
htmlContent = htmlContent + "\n" + dirLink + "\n" + latestPostsHTML + "\n" + dirLink
}
// Handle site-headline if present
// You can add <site-headline>Your Text</site-headline> in your markdown
// For now, we just let it pass through as HTML, or you can add specific replacement logic here.
}
// 7. Build Full Page (HTML Shell)
finalPage := templates.BuildFullPage([]byte(htmlContent), meta)
// 8. Cache
_ = StoreCache(file.OriginalPath, finalPage)
return finalPage, nil
}

41
render.go Normal file
View File

@ -0,0 +1,41 @@
// render.go
package main
import (
"net/http"
"path/filepath"
"strings"
)
// RenderPage writes the content with the correct Content-Type.
func RenderPage(w http.ResponseWriter, content []byte, file ContentFile) {
if file.IsMarkdown {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
} else {
// Determine MIME type by extension
ext := strings.ToLower(filepath.Ext(file.OriginalPath))
switch ext {
case ".css":
w.Header().Set("Content-Type", "text/css")
case ".js":
w.Header().Set("Content-Type", "application/javascript")
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".jpg", ".jpeg":
w.Header().Set("Content-Type", "image/jpeg")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
case ".svg":
w.Header().Set("Content-Type", "image/svg+xml")
default:
w.Header().Set("Content-Type", "text/plain")
}
}
w.Write(content)
}
// RenderError handles error states
func RenderError(w http.ResponseWriter, statusCode int, message string) {
http.Error(w, message, statusCode)
}

68
server.go Normal file
View File

@ -0,0 +1,68 @@
// server.go
package main
import (
"fmt"
"log"
"net/http"
"strings"
)
var globalRoutes RouteMap
// StartServer initializes the routing and starts the HTTP listener.
func StartServer() {
InitCache()
// Initial Scan
var err error
globalRoutes, err = ScanContent("content")
if err != nil {
log.Fatalf("Failed to scan content: %v", err)
}
// SINGLE ENTRY POINT
// We handle all routing manually to ensure strict control.
http.HandleFunc("/", handleRequest)
fmt.Println("Server is online at http://localhost:8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatal(err)
}
}
func handleRequest(w http.ResponseWriter, r *http.Request) {
// 1. Handle Test Mode (Re-scan on every request)
if TestMode {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
// In test mode, we refresh the routes map every time so you can add files
// without restarting.
routes, err := ScanContent("content")
if err == nil {
globalRoutes = routes
}
}
// 2. Normalize Request Path
reqPath := strings.ToLower(r.URL.Path)
// 3. Look up in Route Map
file, found := globalRoutes[reqPath]
if !found {
// If not found in our map, it doesn't exist. 404.
// This prevents the "Index Fallback" bug.
http.NotFound(w, r)
return
}
// 4. Get Content (From Cache or Parse)
content, err := GetContent(file)
if err != nil {
log.Printf("Error serving %s: %v", file.OriginalPath, err)
http.Error(w, "Internal Server Error", 500)
return
}
// 5. Render
RenderPage(w, content, file)
}

116
service.go Normal file
View File

@ -0,0 +1,116 @@
// service.go
package main
import (
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"text/template"
)
const serviceTemplate = `[Unit]
Description=Blog Server
After=network.target
[Service]
Type=simple
User={{.User}}
WorkingDirectory={{.WorkDir}}
ExecStart={{.BinaryPath}}
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
`
type ServiceConfig struct {
User string
WorkDir string
BinaryPath string
}
func InstallService() {
// 1. Get Absolute Path to Binary
binPath, err := os.Executable()
if err != nil {
fmt.Printf("Error getting executable path: %v\n", err)
return
}
binPath, _ = filepath.Abs(binPath)
// 2. Get Working Directory
workDir, err := os.Getwd()
if err != nil {
fmt.Printf("Error getting working directory: %v\n", err)
return
}
// 3. Determine User
// If running as sudo, try to get the original user, otherwise use current
username := os.Getenv("SUDO_USER")
if username == "" {
u, err := user.Current()
if err == nil {
username = u.Username
} else {
username = "root"
}
}
config := ServiceConfig{
User: username,
WorkDir: workDir,
BinaryPath: binPath,
}
// 4. Generate Content
tmpl, err := template.New("service").Parse(serviceTemplate)
if err != nil {
panic(err)
}
serviceFile := "/etc/systemd/system/blog.service"
// Check if we have write permissions to /etc/systemd/system
f, err := os.OpenFile(serviceFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
// Permission denied or other error: Print instructions instead
fmt.Println("---------------------------------------------------")
fmt.Println("Run the following command with sudo to install:")
fmt.Println("---------------------------------------------------")
fmt.Printf("sudo bash -c 'cat > %s <<EOF\n", serviceFile)
tmpl.Execute(os.Stdout, config)
fmt.Println("EOF'")
fmt.Println("---------------------------------------------------")
fmt.Println("Then run:")
fmt.Println("sudo systemctl daemon-reload")
fmt.Println("sudo systemctl enable --now blog")
return
}
defer f.Close()
// Write file
if err := tmpl.Execute(f, config); err != nil {
fmt.Printf("Error writing service file: %v\n", err)
return
}
fmt.Printf("Service file created at %s\n", serviceFile)
// 5. Reload Daemon
cmd := exec.Command("systemctl", "daemon-reload")
if err := cmd.Run(); err != nil {
fmt.Println("Warning: Failed to reload systemd daemon. Run 'sudo systemctl daemon-reload' manually.")
}
// 6. Enable and Start
cmd = exec.Command("systemctl", "enable", "--now", "blog")
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Printf("Warning: Failed to enable service: %v\nOutput: %s\n", err, output)
} else {
fmt.Println("Service installed, enabled, and started successfully!")
}
}

40
templates/index.go Normal file
View File

@ -0,0 +1,40 @@
// templates/index.go
package templates
import (
"bytes"
"fmt"
"time"
)
// PostSnippet represents data needed for the index list
type PostSnippet struct {
Title string
URL string
Date time.Time
}
// RenderLatestPosts generates the HTML for the list of recent posts.
// It accepts a list of posts (already sorted).
func RenderLatestPosts(posts []PostSnippet) string {
var buf bytes.Buffer
buf.WriteString(`<div class="latest-posts"><h2>Latest Posts</h2><ul>`)
for _, p := range posts {
dateStr := p.Date.Format("Jan 02, 2006")
buf.WriteString(fmt.Sprintf(`<li><span class="date">%s</span> <a href="%s">%s</a></li>`, dateStr, p.URL, p.Title))
}
buf.WriteString(`</ul></div>`)
return buf.String()
}
// RenderDirectoryLink generates the navigation link to the full archive.
func RenderDirectoryLink() string {
return `<div class="directory-link"><a href="/archive">View All Posts</a></div>`
}
// RenderSiteHeadline generates the top banner.
func RenderSiteHeadline(text string) string {
return fmt.Sprintf(`<div class="site-headline"><h1>%s</h1></div>`, text)
}

59
templates/style.go Normal file
View File

@ -0,0 +1,59 @@
// templates/style.go
package templates
import (
"bytes"
"fmt"
)
// PageMetadata holds the frontmatter data
type PageMetadata struct {
Title string `toml:"title" yaml:"title"`
Stylesheet string `toml:"stylesheet" yaml:"stylesheet"`
Style string `toml:"style" yaml:"style"` // Inline CSS
}
// BuildFullPage wraps the content in the HTML shell, injecting metadata.
func BuildFullPage(content []byte, meta PageMetadata) []byte {
var buf bytes.Buffer
// Default title if missing
title := meta.Title
if title == "" {
title = "Blog"
}
// Default stylesheet if missing
cssLink := `<link rel="stylesheet" href="/default.css">`
if meta.Stylesheet != "" {
cssLink = fmt.Sprintf(`<link rel="stylesheet" href="%s">`, meta.Stylesheet)
}
buf.WriteString(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>`)
buf.WriteString(title)
buf.WriteString(`</title>
`)
buf.WriteString(cssLink)
if meta.Style != "" {
buf.WriteString(`<style>`)
buf.WriteString(meta.Style)
buf.WriteString(`</style>`)
}
buf.WriteString(`
</head>
<body>
`)
buf.Write(content)
buf.WriteString(`
</body>
</html>`)
return buf.Bytes()
}