diff --git a/main.go b/main.go new file mode 100644 index 0000000..02fa32e --- /dev/null +++ b/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "log" + "slices" +) + +func main() { + // main is the entry point of the application. + // It orchestrates the process of generating the static website by: + // 1. Listing all posts. + // 2. Compiling SCSS styles. + // 3. Rendering each post. + // 4. Rendering the home page. + // 5. Copying static files. + // 6. Rendering tag pages. + // 7. Copying media files. + posts, _ := listPosts() + var tags []string + postsByTag := make(map[string][]Post) + + for _, p := range posts { + for _, t := range p.Tags { + if !slices.Contains(tags, t) { + tags = append(tags, t) + } + + if postsByTag[t] == nil { + postsByTag[t] = []Post{} + } + + postsByTag[t] = append(postsByTag[t], p) + } + } + + css, _ := compileSCSS() + + log.Println(len(posts), "posts to handle") + + for _, p := range posts { + _ = renderPost(p, css, tags) + } + + // Process the index.html template + if err := renderHome(posts, tags, css); err != nil { + log.Fatal("Error processing index template:", err) + } + + // Copy the "static" folder to the "build" folder + if err := copyDir("static/", "build"); err != nil { + log.Fatal("Error copying static files:", err) + } + + for _, t := range tags { + renderTagPage(t, postsByTag[t], tags, css) + } + + if err := copyMedias(); err != nil { + log.Fatal("Erro copying media files:", err) + } +} diff --git a/medias.go b/medias.go new file mode 100644 index 0000000..3251819 --- /dev/null +++ b/medias.go @@ -0,0 +1,39 @@ +package main + +import ( + "errors" + "io/fs" + "log" + "os" + "path/filepath" + "strings" +) + +// copyMedias copies media files from the posts directory to the build/medias directory. +// It creates the build/medias directory if it doesn't exist. +// It walks through the posts directory and copies all .jpg, .jpeg, .png, and .mp4 files. +// Returns any error encountered during the process. +func copyMedias() error { + if err := os.MkdirAll("build/medias", os.ModePerm); err != nil { + log.Fatal("Error creating directory:", err) + return err + } + + filepath.WalkDir("posts/", func(s string, d fs.DirEntry, err error) error { + if filepath.Ext(s) == ".jpg" || filepath.Ext(s) == ".jpeg" || filepath.Ext(s) == ".png" || filepath.Ext(s) == ".mp4" { + newPath := strings.ReplaceAll(s, "posts/", "build/medias/") + + if _, err := os.Stat(newPath); err == nil { + log.Println("Media", newPath, "already handled") + } else if errors.Is(err, os.ErrNotExist) { + err := os.Link(s, newPath) + if err != nil { + log.Fatal("Failed to handle media", s) + } + log.Println("Copyied media from", s, "to", newPath) + } + } + return nil + }) + return nil +} diff --git a/parse.go b/parse.go new file mode 100644 index 0000000..2025c4e --- /dev/null +++ b/parse.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + "log" + "os" + "path" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/niklasfasching/go-org/org" +) + +// Post represents a blog post with metadata and content. +type Post struct { + Title string // Title of the post + Slug string // URL-friendly identifier for the post + Tags []string // Tags associated with the post + Description string // Brief description of the post + Date time.Time // Date when the post was published + DateStr string // Date when the post was published (YYYY-MM-DD) + Timestamp int64 // Unix timestamp of the publication date + Path string // File path to the original .org file + PathHtml string // URL path to the rendered HTML file + Content *org.Document // Parsed content of the post + ReadTime uint8 // Estimated reading time in minutes + Hero string // URL path to the hero image for the post +} + +// listPosts reads the posts directory and returns a slice of Post structs. +// It filters out non-.org files, parses each .org file, and sorts the posts by date in descending order. +// Returns the slice of posts and any error encountered during the process. +func listPosts() ([]Post, error) { + entries, err := os.ReadDir("posts") + if err != nil { + fmt.Println("Error reading directory:", err) + return nil, err + } + + entries = filter(entries, func(e os.DirEntry) bool { return filepath.Ext(e.Name()) == ".org" }) + + var posts []Post + for _, entry := range entries { + filePath := filepath.Join("posts", entry.Name()) + + post, err := parseOrg(filePath) + if err != nil { + log.Println("[!] Unable to parse ", filePath) + } else { + posts = append(posts, post) + } + } + + sort.Slice(posts, func(i, j int) bool { + return posts[i].Timestamp > posts[j].Timestamp + }) + + return posts, nil +} + +// handleImages processes image and video links in the org document. +// It updates the URL of the link to point to the media directory. +// Parameters: +// - protocol: The protocol of the link (e.g., "file", "http"). +// - description: The description of the link. +// - link: The URL of the link. +// +// Returns: +// - The processed link node. +func handleImages(protocol string, description []org.Node, link string) org.Node { + linked := org.RegularLink{protocol, description, link, false} + if linked.Kind() == "image" || linked.Kind() == "video" { + linked.URL = path.Join("/medias/", linked.URL) + } + return linked +} + +// parseOrg parses an org file and returns a Post struct. +// It reads the file, extracts metadata, and calculates the reading time. +// Parameters: +// - filePath: The path to the org file. +// +// Returns: +// - The parsed Post struct. +// - Any error encountered during the process. +func parseOrg(filePath string) (Post, error) { + file, err := os.Open(filePath) + if err != nil { + log.Fatal("Error reading file") + return Post{}, err + } + + config := org.New() + config.ResolveLink = handleImages + + orgData := config.Parse(file, filePath) + + title := orgData.Get("TITLE") + description := orgData.Get("DESCRIPTION") + dateStr := strings.Split(orgData.Get("DATE"), "T")[0] + slug := orgData.Get("SLUG") + tags := strings.Split(orgData.Get("TAGS"), ", ") + hero := path.Join("/medias", orgData.Get("HERO")) + + date, _ := time.Parse("2006-01-02", dateStr) + ts := date.Unix() + + raw, _ := os.ReadFile(filePath) + readTime := len(strings.Split(string(raw), " ")) / 200 + + return Post{ + Title: title, + Slug: slug, + Tags: tags, + Description: description, + Date: date, + DateStr: date.Format("2006-01-02"), + Timestamp: ts, + Path: filePath, + PathHtml: "/posts/" + slug + ".html", + Content: orgData, + ReadTime: uint8(readTime), + Hero: hero, + }, nil +} diff --git a/render.go b/render.go new file mode 100644 index 0000000..79e7f55 --- /dev/null +++ b/render.go @@ -0,0 +1,254 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "os" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/niklasfasching/go-org/org" +) + +// renderHome renders the home page of the website. +// It processes the index template, executes it with the provided posts and tags, +// and writes the resulting HTML to the build directory. +// Parameters: +// - posts: A slice of Post structs representing the blog posts. +// - tags: A slice of strings representing the tags. +// - css: A string containing the compiled CSS styles. +// Returns: +// - An error if any step of the process fails, otherwise nil. +func renderHome(posts []Post, tags []string, css string) error { + indexTmpl, _ := template.ParseFiles("templates/parts/index.html") + var indexContentBuf strings.Builder + indexData := struct { + Posts []Post + }{ + Posts: posts, + } + _ = indexTmpl.Execute(&indexContentBuf, indexData) + + // Parse the index.html template + tmpl, err := template.ParseFiles("templates/layout.html", "templates/parts/header.html") + if err != nil { + return fmt.Errorf("error parsing template: %v", err) + } + + // Create a buffer to hold the template output + var buf strings.Builder + + // Execute the template with the necessary data + data := struct { + Css template.CSS + Content template.HTML + Hero template.HTML + Tags []string + ShowSidebar bool + }{ + Css: template.CSS(css), + Content: template.HTML(indexContentBuf.String()), + Hero: template.HTML("
"), + Tags: tags, + ShowSidebar: true, + } + + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("error executing template: %v", err) + } + + // Create the build directory if it doesn't exist + if err := os.MkdirAll("build", os.ModePerm); err != nil { + return fmt.Errorf("error creating directory: %v", err) + } + + // Write the HTML content to the index.html file in the build directory + if err := os.WriteFile("build/index.html", []byte(buf.String()), 0644); err != nil { + return fmt.Errorf("error writing HTML file: %v", err) + } + + log.Println("Wrote build/index.html") + return nil +} + +// highlightCodeBlock highlights a code block using the specified language and parameters. +// It uses the chroma library to tokenize and format the code block. +// Parameters: +// - source: The source code to highlight. +// - lang: The programming language of the code. +// - inline: Whether the code block is inline or not. +// - params: Additional parameters for highlighting, such as highlighted lines. +// Returns: +// - A string containing the highlighted code block in HTML format. +func highlightCodeBlock(source, lang string, inline bool, params map[string]string) string { + var w strings.Builder + l := lexers.Get(lang) + if l == nil { + l = lexers.Fallback + } + l = chroma.Coalesce(l) + it, _ := l.Tokenise(nil, source) + options := []html.Option{} + if params[":hl_lines"] != "" { + ranges := org.ParseRanges(params[":hl_lines"]) + if ranges != nil { + options = append(options, html.HighlightLines(ranges)) + } + } + _ = html.New(options...).Format(&w, styles.Get("dracula"), it) + if inline { + return `