Init2
This commit is contained in:
commit
1c93ab8607
|
|
@ -0,0 +1,7 @@
|
||||||
|
vendor
|
||||||
|
*.svg
|
||||||
|
*.lock
|
||||||
|
.vscode
|
||||||
|
cache
|
||||||
|
content
|
||||||
|
blog
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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=
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue