From af0332e7d805f7a4cb1086b66f61ad706a082768 Mon Sep 17 00:00:00 2001 From: Himanshu Sardana Date: Wed, 25 Mar 2026 09:21:19 +0000 Subject: refactor: split into cmd/internal --- cmd/main.go | 82 +++++++++++ content/test.md | 310 ---------------------------------------- go.mod | 2 +- home.html | 370 ++++++++++++++++++++++++++++++++++++++---------- internal/build/build.go | 240 +++++++++++++++++++++++++++++++ layout.html | 2 - main.go | 277 +----------------------------------- 7 files changed, 626 insertions(+), 657 deletions(-) create mode 100644 cmd/main.go delete mode 100644 content/test.md create mode 100644 internal/build/build.go diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..afef7bb --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,82 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + + internal "github.com/HimanshuSardana/kite/internal/build" +) + +var themeName = "gruvbox" + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), os.ModePerm); err != nil { + return err + } + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +func main() { + args := os.Args + if len(args) > 1 { + switch args[1] { + case "serve": + copyFile("./themes/"+themeName+"/style.css", "./output/style.css") + + fs := http.FileServer(http.Dir("./output/")) + http.Handle("/", fs) + + log.Println("Serving on http://localhost:8000") + + err := http.ListenAndServe(":8000", nil) + if err != nil { + log.Fatalf("Error occured %s\n", err) + } + case "build": + internal.Build() + case "list-themes": + themeList := make([]string, 0) + themes, err := os.ReadDir("../themes/") + if err != nil { + log.Fatal("Error:", err) + } + for _, theme := range themes { + if theme.IsDir() { + themeList = append(themeList, string(theme.Name())) + } + } + default: + showHelpMessage() + } + } else { + showHelpMessage() + } +} + +func showHelpMessage() { + fmt.Println(` +Usage: kite + +SUBCOMMANDS: +build +serve +`) +} diff --git a/content/test.md b/content/test.md deleted file mode 100644 index a30fca0..0000000 --- a/content/test.md +++ /dev/null @@ -1,310 +0,0 @@ ---- -title: "Writing a Static Site Generator in Golang" ---- - -I recently decided to write a static site generator as a small toy project to get more familiar with the Go standard library. I’ve always liked learning languages by building something practical, and a static site generator seemed like a perfect mix of file handling, templating, and content processing. - -Another reason was infrastructure. My blog used to run on a small VPS from Contabo, but after they raised their prices I decided to move everything to a Raspberry Pi sitting on my desk. Since the Pi isn’t particularly powerful, I wanted the site to be as lean as possible — no runtime rendering, no databases, just plain static files served over HTTP. - -> NOTE -> I decided to call it **Kite**. - -The idea behind Kite is simple: take Markdown files, convert them to HTML, wrap them in a template, and write them to an output directory. That’s it. - ---- - -## Project Structure - -The generator assumes a simple structure: - -``` -. -├── content/ -│ ├── index.md -│ └── blog/ -│ └── first-post.md -├── output/ -├── layout.html -└── main.go -``` - -* **content/** contains Markdown files -* **layout.html** is the HTML template -* **output/** is where generated pages go - -Each Markdown file gets converted into an HTML file with the same relative path. - -For example: - -``` -content/blog/post.md -``` - -becomes: - -``` -output/blog/post.html -``` - ---- - -## The Page Structure - -First we define a simple struct that represents the data passed to our template. - -```go -type Page struct { - Title string - Content template.HTML -} -``` - -* **Title** will eventually hold the page title (hardcoded for now). -* **Content** contains the rendered HTML. - -Notice the use of `template.HTML`. This tells Go’s template engine that the content is already trusted HTML and should not be escaped. - ---- - -## Walking the Content Directory - -The core of the generator uses `filepath.WalkDir` to recursively traverse the `content/` directory. - -```go -err := filepath.WalkDir(contentDir, func(path string, d fs.DirEntry, err error) error { -``` - -For every file encountered, we: - -1. Ignore directories -2. Check if the file ends with `.md` -3. Convert it to HTML -4. Render it into a template -5. Write the result to the output directory - -Filtering Markdown files is straightforward: - -```go -if strings.HasSuffix(d.Name(), ".md") { - fmt.Println("Processing:", path) -``` - ---- - -## Converting Markdown to HTML - -For Markdown parsing I used the `gomarkdown/markdown` package. - -```go -func convertToHtml(path string) []byte { - md, err := os.ReadFile(path) - if err != nil { - log.Fatalf("Error reading %s: %s", path, err) - } - - html := markdown.ToHTML(md, nil, nil) - return html -} -``` - -This function simply: - -1. Reads the Markdown file -2. Converts it to HTML -3. Returns the result - -No configuration, no extensions — just the default behavior. - ---- - -## Creating the Page Object - -After conversion we construct the page object: - -```go -htmlContent := convertToHtml(path) - -newPage := Page{ - Title: "hello", // you can customize this later - Content: template.HTML(htmlContent), -} -``` - -Right now the title is hardcoded, but later this could be extracted from: - -* frontmatter -* the first heading -* metadata in the Markdown file - ---- - -## Parsing the Layout Template - -Next we load the HTML template that wraps the content. - -```go -tmpl, err := template.ParseFiles("./layout.html") -if err != nil { - log.Fatalf("Error parsing template: %s", err) -} -``` - -This happens once per file in the current version. It could be optimized by parsing the template only once before the directory walk, but for a small site this overhead is negligible. - ---- - -## Computing the Output Path - -One important step is preserving the directory structure. - -```go -relPath, err := filepath.Rel(contentDir, path) -``` - -This converts something like: - -``` -content/blog/post.md -``` - -into: - -``` -blog/post.md -``` - -Then we swap the extension: - -```go -outputFilePath := filepath.Join( - outputDir, - strings.Replace(relPath, ".md", ".html", 1), -) -``` - -Result: - -``` -output/blog/post.html -``` - ---- - -## Ensuring Directories Exist - -Before writing the file we make sure the directory exists: - -```go -err = os.MkdirAll(filepath.Dir(outputFilePath), 0o755) -``` - -This creates any missing folders in the path. - ---- - -## Writing the Final HTML - -Finally we create the output file and render the template: - -```go -outputFile, err := os.Create(outputFilePath) -if err != nil { - log.Fatalf("Error creating output file: %s", err) -} -defer outputFile.Close() - -err = tmpl.Execute(outputFile, newPage) -if err != nil { - log.Fatalf("Error generating output content: %s", err) -} -``` - -The template receives the `Page` struct and generates the final HTML. - ---- - -## Example Layout Template - -A minimal `layout.html` might look like this: - -```html - - - - - {{ .Title }} - - -
- {{ .Content }} -
- - -``` - -When the generator runs, the Markdown content gets injected into `{{ .Content }}`. - ---- - -## Running the Generator - -Just run: - -``` -go run main.go -``` - -Example output: - -``` -Processing: content/index.md -Processing: content/blog/first-post.md -All files processed! -``` - -And the generated files appear in `output/`. - ---- - -## Why This Is Nice - -Even this tiny generator already has a few advantages: - -* **Extremely fast builds** -* **No runtime dependencies** -* **Very small hosting requirements** -* **Full control over the pipeline** - -Perfect for something like a Raspberry Pi server. - ---- - -## Future Improvements - -Kite is intentionally minimal right now, but there are plenty of directions to take it: - -* Frontmatter support (YAML/TOML) -* Automatic title extraction -* Template caching -* Asset copying (CSS, images) -* Incremental builds -* Live reload during development -* RSS feed generation -* Tag and category pages - -The nice part is that Go’s standard library already provides most of the building blocks needed. - ---- - -## Final Thoughts - -Writing a static site generator is one of those projects that’s small enough to finish but complex enough to teach useful concepts: - -* filesystem traversal -* templating -* content pipelines -* project structure - -And the result is something genuinely useful. - -Kite might stay a simple tool, or it might slowly grow features as I need them. Either way, it’s already doing exactly what I wanted: generating a lightweight blog that my Raspberry Pi can serve effortlessly. diff --git a/go.mod b/go.mod index 28d182c..9f43bd1 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module kite +module github.com/HimanshuSardana/kite go 1.25.0 diff --git a/home.html b/home.html index afdbbcb..0e963fe 100644 --- a/home.html +++ b/home.html @@ -4,8 +4,141 @@ {{ .SiteTitle }} - + + + - - - +
+ + +
+

{{ .AuthorName }}

+

{{ .AuthorRole }}

+

{{ .AuthorBio }}

+
-
- - - {{ if .Posts }} - - {{ else }} -

No posts yet.

- {{ end }} -
- -
- © {{ .Year }} {{ .AuthorName }} - Built with kite -
+ + +
+ © {{ .Year }} {{ .AuthorName }} + Built with kite +
+
diff --git a/internal/build/build.go b/internal/build/build.go new file mode 100644 index 0000000..1fe85b3 --- /dev/null +++ b/internal/build/build.go @@ -0,0 +1,240 @@ +package build + +import ( + "fmt" + "html/template" + "io/fs" + "log" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v2" + + "github.com/adrg/frontmatter" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/ast" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" +) + +var ( + themeName = "gruvbox" + contentDir = "./content" + outputDir = "./output" +) + +type Frontmatter struct { + Title string `yaml:"title"` + Date string `yaml:"date"` + Tags []string `yaml:"tags"` +} + +type Page struct { + Title string + Content template.HTML + TOC []TOCItem +} + +type TOCItem struct { + Level int + Text string + ID string +} + +type PostSummary struct { + Title string + Slug string + Date string + Tags []string +} + +type HomePage struct { + SiteTitle string `yaml:"siteTitle"` + AuthorName string `yaml:"authorName"` + AuthorRole string `yaml:"authorRole"` + AuthorBio string `yaml:"authorBio"` + Year int + Posts []PostSummary +} + +var posts = make([]Post, 0) + +type Post struct { + Title string +} + +func Build() { + summaries := make([]PostSummary, 0) + + err := filepath.WalkDir(contentDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + if strings.HasSuffix(d.Name(), ".md") { + fmt.Println("Processing:", path) + + matter, htmlContent, toc := convertToHtml(path) + slug := strings.TrimSuffix(d.Name(), ".md") + summaries = append(summaries, PostSummary{ + Title: matter.Title, + Slug: slug, + Date: matter.Date, + Tags: matter.Tags, + }) + + posts = append(posts, Post{Title: matter.Title}) + // fmt.Println("Appended post", posts) + newPage := Page{ + Title: matter.Title, + Content: template.HTML(htmlContent), + TOC: toc, + } + + tmpl, err := template.ParseFiles("./themes/" + themeName + "/layout.html") + if err != nil { + log.Fatalf("Error parsing template: %s", err) + } + + relPath, err := filepath.Rel(contentDir, path) + if err != nil { + log.Fatalf("Error computing relative path: %s", err) + } + // test.md -> test.html + // test.md -> test/index.html + outputFilePath := filepath.Join(outputDir, strings.Replace(relPath, ".md", "/index.html", 1)) + + err = os.MkdirAll(filepath.Dir(outputFilePath), 0o755) + if err != nil { + log.Fatalf("Error creating directories: %s", err) + } + + outputFile, err := os.Create(outputFilePath) + if err != nil { + log.Fatalf("Error creating output file: %s", err) + } + defer outputFile.Close() + + err = tmpl.Execute(outputFile, newPage) + if err != nil { + log.Fatalf("Error generating output content: %s", err) + } + } + + return nil + }) + if err != nil { + log.Fatalf("Error walking directory: %s", err) + } + + fmt.Println("All files processed!") + + fmt.Println(posts) + + renderHomePage(summaries, outputDir) +} + +func convertToHtml(path string) (Frontmatter, []byte, []TOCItem) { + md, err := os.ReadFile(path) + if err != nil { + log.Fatalf("Error reading %s: %s", path, err) + } + + var matter Frontmatter + rest, err := frontmatter.Parse(strings.NewReader(string(md)), &matter) + if err != nil { + log.Fatalf("Error parsing frontmatter: %s", err) + } + + extensions := parser.CommonExtensions | parser.AutoHeadingIDs + p := parser.NewWithExtensions(extensions) + + doc := p.Parse(rest) + + var toc []TOCItem + + ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus { + if heading, ok := node.(*ast.Heading); ok && entering { + text := extractText(heading) + id := string(heading.HeadingID) + + toc = append(toc, TOCItem{ + Level: heading.Level, + Text: text, + ID: id, + }) + } + return ast.GoToNext + }) + + renderer := html.NewRenderer(html.RendererOptions{ + Flags: html.CommonFlags, + }) + + output := markdown.Render(doc, renderer) + + return matter, output, toc +} + +func extractText(h *ast.Heading) string { + var text string + ast.WalkFunc(h, func(node ast.Node, entering bool) ast.WalkStatus { + if leaf, ok := node.(*ast.Text); ok && entering { + text += string(leaf.Literal) + } + return ast.GoToNext + }) + return text +} + +func renderHomePage(summaries []PostSummary, outputDir string) { + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].Date > summaries[j].Date + }) + + for i, p := range summaries { + if t, err := time.Parse("2006-01-02", p.Date); err == nil { + summaries[i].Date = t.Format("Jan 2006") + } + } + + config, err := os.ReadFile("config.yaml") + if err != nil { + panic(err) + } + var data HomePage + err = yaml.Unmarshal(config, &data) + data.Posts = summaries + data.Year = time.Now().Year() + + if err != nil { + panic(err) + } + + tmpl, err := template.ParseFiles("./themes/" + themeName + "/home.html") + if err != nil { + log.Fatalf("Error parsing home template: %s", err) + } + + outPath := filepath.Join(outputDir, "index.html") + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + log.Fatalf("Error creating output dir: %s", err) + } + f, err := os.Create(outPath) + if err != nil { + log.Fatalf("Error creating index.html: %s", err) + } + defer f.Close() + + if err := tmpl.Execute(f, data); err != nil { + log.Fatalf("Error rendering home page: %s", err) + } + fmt.Println("Home page written to", outPath) +} diff --git a/layout.html b/layout.html index 68772ef..97bdb4b 100644 --- a/layout.html +++ b/layout.html @@ -180,8 +180,6 @@ Contents - -