commit 1c93ab86070a1ccf1719d2017787aa32d4907af8 Author: root Date: Thu Feb 12 11:56:26 2026 +0000 Init2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec1aff1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +vendor +*.svg +*.lock +.vscode +cache +content +blog \ No newline at end of file diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..f23be2a --- /dev/null +++ b/cache.go @@ -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) +} diff --git a/filesystem.go b/filesystem.go new file mode 100644 index 0000000..920c02e --- /dev/null +++ b/filesystem.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f79cdfb --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3716b1c --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ba56832 --- /dev/null +++ b/main.go @@ -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() +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..98096ad --- /dev/null +++ b/parse.go @@ -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 ) 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, "") { + htmlContent = strings.ReplaceAll(htmlContent, "", latestPostsHTML) + } else { + // Fallback: Append if not present + htmlContent = htmlContent + "\n" + dirLink + "\n" + latestPostsHTML + "\n" + dirLink + } + + // Handle site-headline if present + // You can add Your Text 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 +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..079fc0f --- /dev/null +++ b/render.go @@ -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) +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..a46b3d3 --- /dev/null +++ b/server.go @@ -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) +} diff --git a/service.go b/service.go new file mode 100644 index 0000000..72b0da3 --- /dev/null +++ b/service.go @@ -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 <

Latest Posts

    `) + + for _, p := range posts { + dateStr := p.Date.Format("Jan 02, 2006") + buf.WriteString(fmt.Sprintf(`
  • %s %s
  • `, dateStr, p.URL, p.Title)) + } + + buf.WriteString(`
`) + return buf.String() +} + +// RenderDirectoryLink generates the navigation link to the full archive. +func RenderDirectoryLink() string { + return `` +} + +// RenderSiteHeadline generates the top banner. +func RenderSiteHeadline(text string) string { + return fmt.Sprintf(`

%s

`, text) +} diff --git a/templates/style.go b/templates/style.go new file mode 100644 index 0000000..42b158e --- /dev/null +++ b/templates/style.go @@ -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 := `` + if meta.Stylesheet != "" { + cssLink = fmt.Sprintf(``, meta.Stylesheet) + } + + buf.WriteString(` + + + + + `) + buf.WriteString(title) + buf.WriteString(` + `) + buf.WriteString(cssLink) + + if meta.Style != "" { + buf.WriteString(``) + } + + buf.WriteString(` + + +`) + buf.Write(content) + buf.WriteString(` + +`) + + return buf.Bytes() +}