diff options
| -rw-r--r-- | cmd/main.go | 82 | ||||
| -rw-r--r-- | content/test.md | 310 | ||||
| -rw-r--r-- | go.mod | 2 | ||||
| -rw-r--r-- | home.html | 364 | ||||
| -rw-r--r-- | internal/build/build.go | 240 | ||||
| -rw-r--r-- | layout.html | 2 | ||||
| -rw-r--r-- | main.go | 277 |
7 files changed, 623 insertions, 654 deletions
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 <SUBCOMMAND> + +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 -<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8"> - <title>{{ .Title }}</title> -</head> -<body> - <main> - {{ .Content }} - </main> -</body> -</html> -``` - -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. @@ -1,4 +1,4 @@ -module kite +module github.com/HimanshuSardana/kite go 1.25.0 @@ -4,8 +4,141 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>{{ .SiteTitle }}</title> - <link rel="stylesheet" href="/style.css" /> + <link rel="preconnect" href="https://fonts.googleapis.com" /> + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> + <link + href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@300;400;500&family=Geist+Mono:wght@400;500&display=swap" + rel="stylesheet" + /> <style> + :root { + --bg: #f9f8f6; + --surface: #ffffff; + --border: #e4e3df; + --text: #111110; + --text-2: #5c5b57; + --text-3: #a8a7a2; + --warm: #b85c38; + --serif: "Instrument Serif", Georgia, serif; + --sans: "Geist", system-ui, sans-serif; + --mono: "Geist Mono", ui-monospace, monospace; + --ease: cubic-bezier(0.16, 1, 0.3, 1); + } + + *, + *::before, + *::after { + box-sizing: border-box; + } + + html { + scroll-behavior: smooth; + } + + body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: var(--sans); + -webkit-font-smoothing: antialiased; + min-height: 100vh; + } + + /* ── Layout ── */ + .page { + max-width: 640px; + margin: 0 auto; + padding: 0 2rem; + } + + /* ── Nav ── */ + nav { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 2rem 0 0; + gap: 2rem; + } + + nav a { + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-3); + text-decoration: none; + transition: color 120ms ease; + } + + nav a:hover { + color: var(--text); + } + + /* ── Hero ── */ + .hero { + padding: 5rem 0 4rem; + border-bottom: 1px solid var(--border); + } + + .hero-name { + font-family: var(--serif); + font-size: clamp(2.8rem, 8vw, 4.5rem); + font-weight: 400; + line-height: 1.05; + letter-spacing: -0.01em; + margin: 0 0 1.2rem; + color: var(--text); + animation: fadeUp 0.7s var(--ease) both; + } + + .hero-name em { + font-style: italic; + color: var(--text-2); + } + + .hero-role { + font-family: var(--mono); + font-size: 0.7rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-3); + margin: 0 0 1.4rem; + animation: fadeUp 0.7s 0.08s var(--ease) both; + } + + .hero-bio { + font-size: 0.975rem; + line-height: 1.8; + color: var(--text-2); + margin: 0; + max-width: 420px; + animation: fadeUp 0.7s 0.15s var(--ease) both; + } + + /* ── Writing section ── */ + .writing-header { + display: flex; + align-items: center; + gap: 1rem; + padding: 2.8rem 0 1.6rem; + } + + .writing-label { + font-family: var(--mono); + font-size: 0.64rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-3); + white-space: nowrap; + } + + .writing-rule { + flex: 1; + height: 1px; + background: var(--border); + } + + /* ── Post list ── */ .post-list { margin: 0; padding: 0; @@ -22,119 +155,208 @@ } .post-item { - display: grid; - gap: 0 1.8rem; + display: flex; align-items: baseline; - padding: 0.85rem 0; + gap: 1.5rem; + padding: 1rem 0; text-decoration: none; color: inherit; - transition: opacity var(--transition); - } - - .post-item:first-child { - border-top: 1px solid var(--border-soft); + border-bottom: 1px solid var(--border); + transition: opacity 140ms ease; + animation: fadeUp 0.5s var(--ease) both; } .post-item:hover { - opacity: 0.6; - text-decoration: none; - } - - .post-item:hover .post-title { - text-decoration-color: var(--text); + opacity: 0.55; } .post-date { font-family: var(--mono); - font-size: 0.68rem; - letter-spacing: 0.06em; - color: var(--text-muted); + font-size: 0.63rem; + letter-spacing: 0.05em; + color: var(--text-3); white-space: nowrap; - padding-top: 0.08em; + flex-shrink: 0; + padding-top: 0.1em; + min-width: 5.5rem; + } + + .post-right { + flex: 1; + min-width: 0; } .post-title { font-family: var(--serif); - font-size: 0.97rem; + font-size: 1.05rem; font-weight: 400; color: var(--text); - line-height: 1.5; - text-decoration-line: underline; - text-decoration-color: transparent; - text-underline-offset: 3px; - text-decoration-thickness: 1px; - transition: text-decoration-color var(--transition); + line-height: 1.45; + display: block; } .post-tags { display: flex; - gap: 0.35rem; - margin-top: 0.3rem; + gap: 0.3rem; + margin-top: 0.4rem; flex-wrap: wrap; } + .pill { + display: inline-block; + font-family: var(--mono); + font-size: 0.58rem; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.15rem 0.45rem; + background: transparent; + border: 1px solid var(--border); + border-radius: 100px; + color: var(--text-3); + } + + /* ── Empty state ── */ .empty { font-family: var(--mono); - font-size: 0.75rem; - color: var(--text-muted); + font-size: 0.72rem; + color: var(--text-3); letter-spacing: 0.06em; - padding: 2rem 0; + padding: 2.5rem 0; + } + + /* ── Footer ── */ + footer { + padding: 2.5rem 0 3rem; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.8rem; + font-family: var(--mono); + font-size: 0.65rem; + letter-spacing: 0.05em; + color: var(--text-3); + } + + footer a { + color: inherit; + text-decoration: underline; + text-underline-offset: 3px; + text-decoration-color: var(--border); + transition: color 120ms ease; + } + + footer a:hover { + color: var(--text); + } + + /* ── Animations ── */ + @keyframes fadeUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } + /* stagger post items */ + .post-item:nth-child(1) { + animation-delay: 0.2s; + } + .post-item:nth-child(2) { + animation-delay: 0.27s; + } + .post-item:nth-child(3) { + animation-delay: 0.34s; + } + .post-item:nth-child(4) { + animation-delay: 0.41s; + } + .post-item:nth-child(5) { + animation-delay: 0.48s; + } + .post-item:nth-child(6) { + animation-delay: 0.55s; + } + + /* ── Mobile ── */ @media (max-width: 500px) { + .page { + padding: 0 1.4rem; + } + .hero { + padding: 3.5rem 0 3rem; + } .post-item { - grid-template-columns: 1fr; + flex-direction: column; gap: 0.2rem; } + .post-date { + min-width: unset; + } + footer { + flex-direction: column; + } + } + + :focus-visible { + outline: 2px solid var(--text); + outline-offset: 3px; + border-radius: 2px; } </style> </head> <body> - <nav> - <a class="logo" href="/">{{ .SiteTitle }}</a> - <ul> - <li><a href="/about">About</a></li> - <li><a href="/feed.xml">RSS</a></li> - </ul> - </nav> + <div class="page"> + <nav> + <a href="/about">About</a> + <a href="/feed.xml">RSS</a> + </nav> - <div class="site-header"> - <h1>{{ .AuthorName }}</h1> - <p class="role">{{ .AuthorRole }}</p> - <p class="intro">{{ .AuthorBio }}</p> - </div> + <header class="hero"> + <h1 class="hero-name">{{ .AuthorName }}</h1> + <p class="hero-role">{{ .AuthorRole }}</p> + <p class="hero-bio">{{ .AuthorBio }}</p> + </header> - <main> - <p class="section-label">Writing</p> + <main> + <div class="writing-header"> + <span class="writing-label">Writing</span> + <span class="writing-rule"></span> + </div> - {{ if .Posts }} - <ul class="post-list"> - {{ range .Posts }} - <li> - <a class="post-item" href="/{{ .Slug }}/"> - <span class="post-date">{{ .Date }}</span> - <span> - <span class="post-title">{{ .Title }}</span> - {{ if .Tags }} - <span class="post-tags"> - {{ range .Tags }} - <span class="pill">{{ . }}</span> + {{ if .Posts }} + <ul class="post-list"> + {{ range .Posts }} + <li> + <a class="post-item" href="/{{ .Slug }}/"> + <span class="post-right"> + <span class="post-title">{{ .Title }}</span> + {{ if .Tags }} + <span class="post-tags"> + {{ range .Tags }} + <span class="pill">{{ . }}</span> + {{ end }} + </span> {{ end }} </span> - {{ end }} - </span> - </a> - </li> + <span class="post-date">{{ .Date }}</span> + </a> + </li> + {{ end }} + </ul> + {{ else }} + <p class="empty">No posts yet.</p> {{ end }} - </ul> - {{ else }} - <p class="empty">No posts yet.</p> - {{ end }} - </main> + </main> - <footer> - <span>© {{ .Year }} {{ .AuthorName }}</span> - <span>Built with <a href="#">kite</a></span> - </footer> + <footer> + <span>© {{ .Year }} {{ .AuthorName }}</span> + <span>Built with <a href="#">kite</a></span> + </footer> + </div> </body> </html> 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 </button> - <p class="toc-title" aria-hidden="true">Contents</p> - <div class="toc-body collapsed" id="toc-body"> <ul> {{ range .TOC }} @@ -2,279 +2,16 @@ package main import ( "fmt" - "html/template" - "io" - "io/fs" - "log" - "net/http" - "os" - "path/filepath" - "sort" - "strings" - "time" _ "net/http/pprof" - - "github.com/adrg/frontmatter" - "github.com/gomarkdown/markdown" - "github.com/gomarkdown/markdown/ast" - "github.com/gomarkdown/markdown/html" - "github.com/gomarkdown/markdown/parser" - "gopkg.in/yaml.v3" ) -type TOCItem struct { - Level int - Text string - ID string -} - -type Page struct { - Title string - Content template.HTML - TOC []TOCItem -} - -type Post struct { - Title string -} -type Frontmatter struct { - Title string `yaml:"title"` - Date string `yaml:"date"` - Tags []string `yaml:"tags"` -} - -var themeName = "gruvbox" - -func main() { - contentDir := "./content" - outputDir := "./output" - - posts := make([]Post, 0) - - 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) - } - } - } - - 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 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 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 -} - -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 -} - -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() +func showHelpMessage() { + fmt.Println(` +Usage: kite <SUBCOMMAND> - if err := tmpl.Execute(f, data); err != nil { - log.Fatalf("Error rendering home page: %s", err) - } - fmt.Println("Home page written to", outPath) +SUBCOMMANDS: +build +serve +`) } |
