From 1c93ab86070a1ccf1719d2017787aa32d4907af8 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Feb 2026 11:56:26 +0000 Subject: [PATCH] Init2 --- .gitignore | 7 +++ cache.go | 89 ++++++++++++++++++++++++++++++++++ filesystem.go | 104 +++++++++++++++++++++++++++++++++++++++ go.mod | 12 +++++ go.sum | 16 ++++++ main.go | 30 ++++++++++++ parse.go | 118 +++++++++++++++++++++++++++++++++++++++++++++ render.go | 41 ++++++++++++++++ server.go | 68 ++++++++++++++++++++++++++ service.go | 116 ++++++++++++++++++++++++++++++++++++++++++++ templates/index.go | 40 +++++++++++++++ templates/style.go | 59 +++++++++++++++++++++++ 12 files changed, 700 insertions(+) create mode 100644 .gitignore create mode 100644 cache.go create mode 100644 filesystem.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 parse.go create mode 100644 render.go create mode 100644 server.go create mode 100644 service.go create mode 100644 templates/index.go create mode 100644 templates/style.go 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() +}