Home

blog @main - refs - log -
-
https://git.jolheiser.com/blog.git
My nonexistent blog
tree log patch
wip Signed-off-by: jolheiser <john.olheiser@gmail.com>
Signature
-----BEGIN PGP SIGNATURE----- iQIzBAABCgAdFiEEgqEQpE3xoo1QwJO/uFOtpdp7v3oFAmXWxIoACgkQuFOtpdp7 v3okNhAAk+lCQ+Qotg3zgadBbvYkHErqQXQrwx+eXWH7WeGg3B0Qj67+fN2W8fx7 aoGhB0JltJPpMT+lCAM7vW4gmAi918/PC6s35lZ9QsABBSlxVKoxQat7ZLOoFFey BsDCpDdsPcXQ2/Qgr1Vop+gNmTGUMfm5yBfs+E9vHaKQcwq5+I5/dZloe75d+nn1 EPRQm8tseGqtO41ZqpZqFGf+qoh23IXTkrrxoC2P3BOxtJJwQXKEi9n4Aoh8rr/E ZQ1P1pL00VSu9QhrWUq9FsPB90uU2aP518/htiD1lJlFNB9Rv+5TJGTha3Jali4g StR3Zy+DWU+1BlKmGNP0hSvcm3+yvIisV/Mco+3bc1xMJlDTe9gYQtLIXDNQhWBG z1BVXT/ITKeHbclZdQpqCwtdLxvvHMeHXlN9KDaihvWXOAIFTd6M9jsdhgNqE2g0 AWfKqzcoCLMsBtc3JRUIypPnwksgdmGK/5zIf2sMw9vJvZd57PdxF3Cv2hjVvfCh I1s4nEATpK1GqPZgmS6nqq9hmHEklqw4idXqQqI9bb2EIGc+YW8R+ukVBN66jaGK HxOZ5ighJlM80DszW9/HlLnk0RMi404zhdxKf+imklhANl6r2cJ7zbvJIgEjCwq5 cw9L1KYTRnHT9XtiNOoKlqkMnU1D+hY9SWSlW0JWd/geV+UMS4A= =Tt3l -----END PGP SIGNATURE-----
jolheiser <john.olheiser@gmail.com>
3 months ago
18 changed files, 524 additions(+), 661 deletions(-)
M .gitignore -> .gitignore
diff --git a/.gitignore b/.gitignore
index c6aa2a4851f3a5271fdf9b86ce78e6e8ce1cbcef..89f9ac04aac6c8ee66e158853e7d0439b3ec782d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1 @@
-/blog*
-!blog.go
-!blog_test.go
-
-_example/out
+out/
D _example/README.md
diff --git a/_example/README.md b/_example/README.md
deleted file mode 100644
index 9a7224f619191edd2bcdf5d9f5acc90976f2e60d..0000000000000000000000000000000000000000
--- a/_example/README.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Example blog structure
-
-The `articles` dir contains a list of articles, namely this one.
-
-The `templates` dir contains a handful of [go templates](https://pkg.go.dev/html/template) that make up how to render the blog.
-- `article.tmpl` is a template for an individual article (this page).
-- `index.tmpl` is the main blog overview.
-- `base.tmpl` is a collection of [defined blocks](https://pkg.go.dev/text/template#hdr-Nested_template_definitions)
-that are then re-used in `index.tmpl` and `article.tmpl` to create a cohesive look-and-feel.
-
-Finally, `config.yaml` (which can also be TOML if desired) allows you to not have to continuously re-type all the flags to define things like your 
-`--article-dir`, `--template-dir`, or `--out`.
-
M _example/articles/introduction.md -> articles/introduction.md
diff --git a/_example/articles/introduction.md b/articles/introduction.md
rename from _example/articles/introduction.md
rename to articles/introduction.md
index 67956be729182b0ac63aa2360d99554f7152f141..221258b975335736ddb331fa0f3d20d269d75495 100644
--- a/_example/articles/introduction.md
+++ b/articles/introduction.md
@@ -1,12 +1,14 @@
----
-title: "Introduction"
-summary: "An introduction of `blog`"
+time: 2024-02-17
 time: 2024-02-17
+time: 2024-02-17
 category: Miscellaneous
+time: 2024-02-17
 authors:
+time: 2024-02-17
   - name: "jolheiser"
+time: 2024-02-17
     email: "john+blog@jolheiser.com"
----
++++
 
 # Hello and welcome to blog!
 
@@ -64,3 +66,4 @@ that are then re-used in `index.tmpl` and `article.tmpl` to create a cohesive look-and-feel.
 
 Finally, `config.yaml` (which can also be TOML if desired) allows you to not have to continuously re-type all the flags to define things like your 
 `--article-dir`, `--template-dir`, or `--out`.
+
D _example/config.yaml
diff --git a/_example/config.yaml b/_example/config.yaml
deleted file mode 100644
index 914646cc3ba1a16edd9b65861c517ff51634acc7..0000000000000000000000000000000000000000
--- a/_example/config.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-article-dir: articles
-template-dir: templates
-out-dir: out
-author:
-  name: jolheiser
-  email: john+blog@jolheiser.com
-  links:
-    - name: GitHub
-      url: "https://github.com/jolheiser"
D _example/templates/article.tmpl
diff --git a/_example/templates/article.tmpl b/_example/templates/article.tmpl
deleted file mode 100644
index db4e9ecb5eab85d4a3d0f5bbc553b3b5f6659c02..0000000000000000000000000000000000000000
--- a/_example/templates/article.tmpl
+++ /dev/null
@@ -1,8 +0,0 @@
-{{template "head" .Article.Title}}
-<body>
-<header>
- <a href="index.html">{{.Author.Name}}</a>
-</header>
-<hr/>
-<main>{{.Article.Content}}</main>
-</body>
D _example/templates/base.tmpl
diff --git a/_example/templates/base.tmpl b/_example/templates/base.tmpl
deleted file mode 100644
index 7a05f6c9c322f6db80c4885b595f3a2c944552ee..0000000000000000000000000000000000000000
--- a/_example/templates/base.tmpl
+++ /dev/null
@@ -1,8 +0,0 @@
-{{define "head"}}
-<!DOCTYPE html>
-<head>
-<title>{{.}}</title>
-<link id="style" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css"/>
-<link rel="stylesheet" href="chroma.css"/>
-</head>
-{{end}}
D _example/templates/index.tmpl
diff --git a/_example/templates/index.tmpl b/_example/templates/index.tmpl
deleted file mode 100644
index 20ddba201b1aab26c30e6d8bc357fddfaaed7f0b..0000000000000000000000000000000000000000
--- a/_example/templates/index.tmpl
+++ /dev/null
@@ -1,17 +0,0 @@
-{{template "head" (printf "%s's Blog" .Author.Name)}}
-<body>
-<header>
-	<h1>{{.Author.Name}}</h1>
-	<p>I really like to hack on Gitea and I use Catppuccin (btw)</p>
-</header>
-<main>
-{{range $category, $articles := .ArticlesByCategory}}
-	<h3>{{$category}}</h3>
-	<ul>
-	{{range $article := $articles}}
-		<li><a href="{{$article.Filename}}.html">{{$article.Title}}</a> ({{$article.Time.Format "01/02/2006"}}) - {{$article.Summary}}</li>
-	{{end}}
-	</ul>
-{{end}}
-</main>
-</body>
D blog.go
diff --git a/blog.go b/blog.go
deleted file mode 100644
index 21f761408aedd5cedc2f77052c46a8941dca5727..0000000000000000000000000000000000000000
--- a/blog.go
+++ /dev/null
@@ -1,244 +0,0 @@
-package blog
-
-import (
-	"bytes"
-	"errors"
-	"fmt"
-	"html/template"
-	"io"
-	"io/fs"
-	"net/url"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-	"time"
-
-	"github.com/bmatcuk/doublestar/v4"
-	"github.com/pelletier/go-toml/v2"
-	"gopkg.in/yaml.v3"
-)
-
-// Blog is a collection of [Article]
-type Blog struct {
-	indexTemplate   *template.Template
-	articleTemplate *template.Template
-	Articles        []Article
-	Author          Author
-}
-
-// Article is a blog post/article
-type Article struct {
-	Filename string
-	Content  template.HTML
-	ArticleMeta
-}
-
-// ArticleMeta is the metadata of an [Article]
-// The meta is parsed from frontmatter in an article
-type ArticleMeta struct {
-	Title    string
-	Subtitle string
-	Summary  string
-	Time     time.Time
-	Author   Author
-	Tags     []string
-	Category string
-}
-
-// Author is an author of a blog/post
-type Author struct {
-	Name  string
-	Job   string
-	Email string
-	Links []Link
-}
-
-// Link is a link name and URL
-type Link struct {
-	Name string
-	URL  LinkURL
-}
-
-// LinkURL is a URL
-type LinkURL url.URL
-
-func (l *LinkURL) UnmarshalText(data []byte) error {
-	u, err := url.Parse(string(data))
-	if err != nil {
-		return err
-	}
-	*l = LinkURL(*u)
-	return nil
-}
-
-// NewBlog constructs a new blog from articles in articleDir and templates in templateDir
-func NewBlog(articleDir, templateDir string, author Author) (*Blog, error) {
-	tmpl, err := parseTemplates(os.DirFS(templateDir))
-	if err != nil {
-		return nil, fmt.Errorf("could not parse templates in %q: %w", templateDir, err)
-	}
-	indexTmpl := tmpl.Lookup("index.tmpl")
-	if indexTmpl == nil {
-		indexTmpl = tmpl.Lookup("index")
-		if indexTmpl == nil {
-			return nil, errors.New("`index` template is required but was not found")
-		}
-	}
-	articleTmpl := tmpl.Lookup("article.tmpl")
-	if articleTmpl == nil {
-		articleTmpl = tmpl.Lookup("article")
-		if articleTmpl == nil {
-			return nil, errors.New("`article` template is required but was not found")
-		}
-	}
-	articles, err := parseArticles(os.DirFS(articleDir))
-	if err != nil {
-		return nil, fmt.Errorf("could not parse articles in %q: %w", articleDir, err)
-	}
-	return &Blog{
-		indexTemplate:   indexTmpl,
-		articleTemplate: articleTmpl,
-		Articles:        articles,
-		Author:          author,
-	}, nil
-}
-
-// Index renders the blog index to w
-func (b *Blog) Index(w io.Writer) error {
-	byCat := make(map[string][]Article)
-	for _, article := range b.Articles {
-		byCat[article.Category] = append(byCat[article.Category], article)
-	}
-	return b.indexTemplate.Execute(w, map[string]any{
-		"Articles":           b.Articles,
-		"ArticlesByCategory": byCat,
-		"Author":             b.Author,
-	})
-}
-
-// Article renders an article to w
-func (b *Blog) Article(w io.Writer, a Article) error {
-	return b.articleTemplate.Execute(w, map[string]any{
-		"Article": a,
-		"Author":  b.Author,
-	})
-}
-
-func parseTemplates(fs fs.FS) (*template.Template, error) {
-	matches, err := doublestar.Glob(fs, "**/*.{tmpl,gohtml}")
-	if err != nil {
-		return nil, fmt.Errorf("could not glob templates: %w", err)
-	}
-	tmpl, err := template.New("").ParseFS(fs, matches...)
-	if err != nil {
-		return nil, fmt.Errorf("could not parse templates: %w", err)
-	}
-	return tmpl, nil
-}
-
-func parseArticles(fs fs.FS) ([]Article, error) {
-	matches, err := doublestar.Glob(fs, "**/*.md")
-	if err != nil {
-		return nil, fmt.Errorf("could not glob articles: %w", err)
-	}
-	articles := make([]Article, 0, len(matches))
-	for _, match := range matches {
-		if err := func() error {
-			fi, err := fs.Open(match)
-			if err != nil {
-				return err
-			}
-			defer fi.Close()
-
-			content, err := io.ReadAll(fi)
-			if err != nil {
-				return err
-			}
-
-			article, err := parseArticle(string(content))
-			if err != nil {
-				return err
-			}
-			article.Filename = strings.TrimSuffix(filepath.Base(match), filepath.Ext(match))
-			articles = append(articles, article)
-			return nil
-		}(); err != nil {
-			return nil, err
-		}
-	}
-	sort.SliceStable(articles, func(i, j int) bool {
-		return articles[i].Time.After(articles[j].Time)
-	})
-	return articles, nil
-}
-
-func parseArticle(content string) (Article, error) {
-	lines := strings.Split(content, "\n")
-
-	start, end := -1, -1
-	var isSep func(string) bool
-	var decoder func([]byte, any) error
-
-	for idx, line := range lines {
-		if strings.TrimSpace(line) == "" {
-			continue
-		}
-		if isSep != nil && isSep(line) {
-			end = idx
-			break
-		}
-
-		if isTOMLSeparator(line) {
-			start = idx
-			isSep = isTOMLSeparator
-			decoder = toml.Unmarshal
-			continue
-		}
-
-		if isYAMLSeparator(line) {
-			start = idx
-			isSep = isYAMLSeparator
-			decoder = yaml.Unmarshal
-			continue
-		}
-	}
-
-	var meta ArticleMeta
-	body := content
-	if start != -1 && end != -1 {
-		body = strings.Join(lines[end+1:], "\n")
-		if err := decoder([]byte(strings.Join(lines[start+1:end], "\n")), &meta); err != nil {
-			return Article{}, fmt.Errorf("could not parse frontmatter: %w", err)
-		}
-	}
-
-	var buf bytes.Buffer
-	err := Markdown.Convert([]byte(body), &buf)
-	if err != nil {
-		return Article{}, fmt.Errorf("could not convert article: %w", err)
-	}
-
-	return Article{
-		Content:     template.HTML(buf.String()),
-		ArticleMeta: meta,
-	}, nil
-}
-
-func isTOMLSeparator(line string) bool {
-	for _, char := range line {
-		if char != '+' {
-			return false
-		}
-	}
-	return true
-}
-
-func isYAMLSeparator(line string) bool {
-	for _, char := range line {
-		if char != '-' {
-			return false
-		}
-	}
-	return true
-}
D blog_test.go
diff --git a/blog_test.go b/blog_test.go
deleted file mode 100644
index 821a659ae04f26a6c4bb1157f8c4835e58de07b2..0000000000000000000000000000000000000000
--- a/blog_test.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package blog
-
-import (
-	"net/url"
-	"testing"
-	"time"
-
-	"github.com/alecthomas/assert"
-)
-
-func TestParseArticle(t *testing.T) {
-	var (
-		raw = `+++
-title = "Honk"
-subtitle = "Bonk"
-summary = """This
-is
-a
-summary"""
-time = 2024-02-16
-tags = ["bocchi", "rocks"]
-		
-[author]
-name = "Bocchi"
-job = "Guitarist"
-email = "bocchi@rock.s"
-
-[[author.links]]
-name = "website"
-url = "https://example.com/bocchi"
-+++
-
-# Hello world
-
-Beep boop`
-		meta = ArticleMeta{
-			Title:    "Honk",
-			Subtitle: "Bonk",
-			Summary: `This
-is
-a
-summary`,
-			Time: func() time.Time {
-				t, _ := time.ParseInLocation("2006-01-02", "2024-02-16", time.Local)
-				return t
-			}(),
-			Author: Author{
-				Name:  "Bocchi",
-				Job:   "Guitarist",
-				Email: "bocchi@rock.s",
-				Links: []Link{
-					{
-						Name: "website",
-						URL: func() LinkURL {
-							u, _ := url.Parse("https://example.com/bocchi")
-							return LinkURL(*u)
-						}(),
-					},
-				},
-			},
-			Tags: []string{"bocchi", "rocks"},
-		}
-		body = `<h1 id="hello-world">Hello world</h1>
-<p>Beep boop</p>
-`
-	)
-
-	article, err := parseArticle(raw)
-	assert.NoError(t, err)
-	assert.Equal(t, meta, article.ArticleMeta)
-	assert.Equal(t, body, article.Content)
-}
D cmd/blog/main.go
diff --git a/cmd/blog/main.go b/cmd/blog/main.go
deleted file mode 100644
index d5d835e2eab4153e10fc157dd07aec9cbb7507d4..0000000000000000000000000000000000000000
--- a/cmd/blog/main.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package main
-
-import (
-	"flag"
-	"fmt"
-	"os"
-	"path/filepath"
-
-	"github.com/pelletier/go-toml/v2"
-	"go.jolheiser.com/blog"
-	"gopkg.in/yaml.v3"
-)
-
-type args struct {
-	ArticleDir  string      `yaml:"article-dir" toml:"article-dir"`
-	TemplateDir string      `yaml:"template-dir" toml:"template-dir"`
-	OutDir      string      `yaml:"out-dir" toml:"out-dir"`
-	Author      blog.Author `yaml:"author" toml:"author"`
-}
-
-func main() {
-	if err := maine(); err != nil {
-		fmt.Println(err)
-		os.Exit(1)
-	}
-}
-
-func maine() error {
-	var args args
-
-	fs := flag.NewFlagSet("blog", flag.ExitOnError)
-
-	configFlag := fs.String("config", "", "Configuration, TOML or YAML")
-	fs.StringVar(configFlag, "c", *configFlag, "--config")
-	articlesFlag := fs.String("articles", "", "Path to articles")
-	fs.StringVar(articlesFlag, "a", *articlesFlag, "--articles")
-	templatesFlag := fs.String("templates", "", "Path to templates")
-	fs.StringVar(templatesFlag, "t", *templatesFlag, "--templates")
-	outFlag := fs.String("out", "", "Path to output")
-	fs.StringVar(outFlag, "o", *outFlag, "--out")
-
-	if err := fs.Parse(os.Args[1:]); err != nil {
-		return err
-	}
-
-	if *configFlag != "" {
-		data, err := os.ReadFile(*configFlag)
-		if err != nil {
-			return err
-		}
-		switch ext := filepath.Ext(*configFlag); ext {
-		case ".yaml", ".yml":
-			if err := yaml.Unmarshal(data, &args); err != nil {
-				return err
-			}
-		case ".toml":
-			if err := toml.Unmarshal(data, &args); err != nil {
-				return err
-			}
-		default:
-			return fmt.Errorf("could not determine config type %q", ext)
-		}
-	}
-
-	if *articlesFlag != "" {
-		args.ArticleDir = *articlesFlag
-	}
-	if args.ArticleDir == "" {
-		args.ArticleDir = "articles"
-	}
-	if *templatesFlag != "" {
-		args.TemplateDir = *templatesFlag
-	}
-	if args.TemplateDir == "" {
-		args.TemplateDir = "templates"
-	}
-	if *outFlag != "" {
-		args.OutDir = *outFlag
-	}
-	if args.OutDir == "" {
-		args.OutDir = "out"
-	}
-
-	if err := os.MkdirAll(args.OutDir, os.ModePerm); err != nil {
-		return err
-	}
-
-	blog, err := blog.NewBlog(args.ArticleDir, args.TemplateDir, args.Author)
-	if err != nil {
-		return err
-	}
-
-	indexFile, err := os.Create(filepath.Join(args.OutDir, "index.html"))
-	if err != nil {
-		return err
-	}
-	defer indexFile.Close()
-	if err := blog.Index(indexFile); err != nil {
-		return err
-	}
-
-	for _, article := range blog.Articles {
-		if err := func() error {
-			articleFile, err := os.Create(filepath.Join(args.OutDir, article.Filename+".html"))
-			if err != nil {
-				return err
-			}
-			defer articleFile.Close()
-			return blog.Article(articleFile, article)
-		}(); err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
D cmd/chromacss/main.go
diff --git a/cmd/chromacss/main.go b/cmd/chromacss/main.go
deleted file mode 100644
index e2b1eefe7aa3763e530c534812bee0f951a768b8..0000000000000000000000000000000000000000
--- a/cmd/chromacss/main.go
+++ /dev/null
@@ -1,72 +0,0 @@
-package main
-
-import (
-	"flag"
-	"fmt"
-	"os"
-
-	"github.com/alecthomas/chroma/v2/formatters/html"
-	"github.com/alecthomas/chroma/v2/styles"
-	"go.jolheiser.com/blog"
-)
-
-func main() {
-	if err := maine(); err != nil {
-		fmt.Println(err)
-		os.Exit(1)
-	}
-}
-
-func maine() error {
-	fs := flag.NewFlagSet("chromacss", flag.ExitOnError)
-	lightFlag := fs.String("light", "catppuccin-latte", "Light theme")
-	fs.StringVar(lightFlag, "l", *lightFlag, "--light")
-	darkFlag := fs.String("dark", "catppuccin-mocha", "Dark theme")
-	fs.StringVar(darkFlag, "d", *darkFlag, "--dark")
-	outFlag := fs.String("out", "", "Output (default: stdout)")
-	fs.StringVar(outFlag, "o", *outFlag, "--out")
-	if err := fs.Parse(os.Args[1:]); err != nil {
-		return err
-	}
-
-	out := os.Stdout
-	if *outFlag != "" {
-		fi, err := os.Create(*outFlag)
-		if err != nil {
-			return err
-		}
-		defer fi.Close()
-		out = fi
-	}
-
-	formatter := html.New(blog.ChromaOpts...)
-
-	lightStyle := *lightFlag
-	darkStyle := *darkFlag
-
-	if lightStyle == "" {
-		lightStyle = "catppuccin-latte"
-		if darkStyle != "" {
-			lightStyle = darkStyle
-			darkStyle = ""
-		}
-	}
-
-	styles.Fallback = styles.Get("catppuccin-latte")
-	style := styles.Get(lightStyle)
-	if err := formatter.WriteCSS(out, style); err != nil {
-		return err
-	}
-
-	if darkStyle != "" {
-		out.WriteString("@media (prefers-color-scheme: dark) {")
-		styles.Fallback = styles.Get("catpuccin-mocha")
-		style = styles.Get(darkStyle)
-		if err := formatter.WriteCSS(out, style); err != nil {
-			return err
-		}
-		out.WriteString("}")
-	}
-
-	return nil
-}
M go.mod -> go.mod
diff --git a/go.mod b/go.mod
index 8c1aa017c7924a7b8ed3b4ceab8cde26ece9de51..3d226dae1f71f357af8b8c8cbd8f38889bcba113 100644
--- a/go.mod
+++ b/go.mod
@@ -3,29 +3,26 @@
 go 1.21.6
 
 require (
-	github.com/BurntSushi/toml v1.3.2 // indirect
+
 	github.com/alecthomas/assert v1.0.0 // indirect
+
 	github.com/alecthomas/chroma/v2 v2.12.0 // indirect
+
 	github.com/alecthomas/colour v0.1.0 // indirect
+
 	github.com/alecthomas/repr v0.2.0 // indirect
-module go.jolheiser.com/blog
+go 1.21.6
-	github.com/dlclark/regexp2 v1.10.0 // indirect
+go 1.21.6
 module go.jolheiser.com/blog
-
-module go.jolheiser.com/blog
 go 1.21.6
+
-	github.com/mattn/go-isatty v0.0.14 // indirect
-module go.jolheiser.com/blog
+
 	github.com/BurntSushi/toml v1.3.2 // indirect
-	github.com/peterbourgon/ff/v3 v3.4.0 // indirect
+
-	github.com/sergi/go-diff v1.2.0 // indirect
+require (
 module go.jolheiser.com/blog
-	github.com/alecthomas/colour v0.1.0 // indirect
 module go.jolheiser.com/blog
-	github.com/alecthomas/repr v0.2.0 // indirect
-	github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc // indirect
+	github.com/kr/pretty v0.3.1 // indirect
-	github.com/yuin/goldmark-meta v1.1.0 // indirect
-	golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect
+	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
-	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
M go.sum -> go.sum
diff --git a/go.sum b/go.sum
index 62a81853e4095cb8c64512a1f9d8ff9e25a8fc83..c4b1bc6fb037c92e5fe68ac1efae39f51b36f823 100644
--- a/go.sum
+++ b/go.sum
@@ -1,67 +1,51 @@
 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/a-h/templ v0.2.543 h1:8YyLvyUtf0/IE2nIwZ62Z/m2o2NqwhnMynzOL78Lzbk=
+github.com/a-h/templ v0.2.543/go.mod h1:jP908DQCwI08IrnTalhzSEH9WJqG/Q94+EODQcJGFUA=
+github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
 github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
+github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
 github.com/alecthomas/assert v1.0.0/go.mod h1:va/d2JC+M7F6s+80kl/R3G7FUiW6JzUO+hPhLyJ36ZY=
-github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY=
 github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
 github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
 github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
-github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrDUk=
-github.com/alecthomas/colour v0.1.0/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
 github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
 github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
 github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
-github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I=
-github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
 github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
-github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
 github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
 github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
 github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
-github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw=
-github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
-github.com/jolheiser/goldmark-meta v0.0.2 h1:3qnvGnp1n7Cdu1L9LpuZrM6dfmbMAS22+hNJnxGPn6s=
-github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
 github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
-github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
 github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
-github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
 github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
-github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
 github.com/alecthomas/colour v0.1.0 h1:nOE9rJm6dsZ66RGWYSFrXw461ZIt9A6+nHgL7FRrDUk=
-github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
+github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
-github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
+github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
 github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
-github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
+github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
-github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
+github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
 github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
-github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
+github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
 github.com/alecthomas/assert v1.0.0/go.mod h1:va/d2JC+M7F6s+80kl/R3G7FUiW6JzUO+hPhLyJ36ZY=
 github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
-github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY=
-github.com/peterbourgon/ff/v3 v3.4.0/go.mod h1:zjJVUhx+twciwfDl0zBcFzl4dW8axCRyXE/eKY9RztQ=
-github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
 github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
-github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
 github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
+github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG67mRDY=
-github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/alecthomas/assert v1.0.0/go.mod h1:va/d2JC+M7F6s+80kl/R3G7FUiW6JzUO+hPhLyJ36ZY=
-github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
 github.com/alecthomas/assert v1.0.0/go.mod h1:va/d2JC+M7F6s+80kl/R3G7FUiW6JzUO+hPhLyJ36ZY=
-github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
-github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/yuin/goldmark v1.3.7 h1:NSaHgaeJFCtWXCBkBKXw0rhgMuJ0VoE9FB5mWldcrQ4=
 github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
-github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0=
 github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark v1.7.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
 github.com/yuin/goldmark v1.7.0/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
@@ -72,15 +56,10 @@ github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
 github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
 github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
 github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
-golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
 github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
-gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
 github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
-github.com/alecthomas/assert v1.0.0/go.mod h1:va/d2JC+M7F6s+80kl/R3G7FUiW6JzUO+hPhLyJ36ZY=
-gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+github.com/alecthomas/assert v1.0.0 h1:3XmGh/PSuLzDbK3W2gUbRXwgW5lqPkuqvRgeQ30FI5o=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
D goldmark.go
diff --git a/goldmark.go b/goldmark.go
deleted file mode 100644
index 195e16337b248d03814a8d54db027cad16c6ec19..0000000000000000000000000000000000000000
--- a/goldmark.go
+++ /dev/null
@@ -1,39 +0,0 @@
-package blog
-
-import (
-	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
-	"github.com/yuin/goldmark"
-	emoji "github.com/yuin/goldmark-emoji"
-	highlighting "github.com/yuin/goldmark-highlighting/v2"
-	meta "github.com/yuin/goldmark-meta"
-	"github.com/yuin/goldmark/extension"
-	"github.com/yuin/goldmark/parser"
-	"github.com/yuin/goldmark/renderer/html"
-)
-
-// Markdown is the default markdown converter
-// Set over this variable to alter how the other functions convert
-var Markdown = goldmark.New(
-	goldmark.WithParserOptions(
-		parser.WithAutoHeadingID(),
-	),
-	goldmark.WithRendererOptions(
-		html.WithUnsafe(),
-	),
-	goldmark.WithExtensions(
-		extension.GFM,
-		meta.Meta,
-		emoji.Emoji,
-		highlighting.NewHighlighting(
-			highlighting.WithFormatOptions(
-				ChromaOpts...,
-			),
-		),
-	),
-)
-
-var ChromaOpts = []chromahtml.Option{
-	chromahtml.WithClasses(true),
-	chromahtml.WithAllClasses(true),
-	chromahtml.WithLineNumbers(true),
-}
I main.go
diff --git a/main.go b/main.go
new file mode 100644
index 0000000000000000000000000000000000000000..f0c5c9ee09a29fe144c863df27be02a3a1bf7a3e
--- /dev/null
+++ b/main.go
@@ -0,0 +1,103 @@
+//go:generate templ generate
+//go:generate go run .
+package main
+
+import (
+	"context"
+	"embed"
+	"flag"
+	"fmt"
+	iofs "io/fs"
+	"os"
+	"path/filepath"
+
+	"github.com/alecthomas/chroma/v2/styles"
+)
+
+//go:embed articles/*
+var articleFS embed.FS
+
+func maine() error {
+	fs := flag.NewFlagSet("blog", flag.ExitOnError)
+	outFlag := fs.String("out", "out", "Output directory")
+	fs.StringVar(outFlag, "o", *outFlag, "--out")
+	if err := fs.Parse(os.Args[1:]); err != nil {
+		return err
+	}
+
+	files, err := articleFS.ReadDir("articles")
+	if err != nil {
+		return err
+	}
+
+	articles := make(Articles)
+	for _, file := range files {
+		if filepath.Ext(file.Name()) != ".md" {
+			continue
+		}
+		content, err := iofs.ReadFile(articleFS, fmt.Sprintf("articles/%s", file.Name()))
+		if err != nil {
+			return err
+		}
+		article, err := Parse(string(content))
+		if err != nil {
+			return err
+		}
+		articles[article.Category] = append(articles[article.Category], article)
+		if err := writeArticle(*outFlag, article); err != nil {
+			return err
+		}
+	}
+
+	if err := writeCSS(*outFlag); err != nil {
+		return err
+	}
+
+	fi, err := os.Create(filepath.Join(*outFlag, "index.html"))
+	if err != nil {
+		return err
+	}
+	defer fi.Close()
+
+	return IndexTemplate(articles).Render(context.Background(), fi)
+}
+
+func writeArticle(out string, article Article) error {
+	dest := filepath.Join(out, article.Slug(), "index.html")
+	if err := os.MkdirAll(filepath.Dir(dest), os.ModePerm); err != nil {
+		return err
+	}
+
+	fi, err := os.Create(dest)
+	if err != nil {
+		return err
+	}
+	defer fi.Close()
+
+	return ArticleTemplate(article).Render(context.Background(), fi)
+}
+
+func writeCSS(out string) error {
+	fi, err := os.Create(filepath.Join(out, "chroma.css"))
+	if err != nil {
+		return err
+	}
+	defer fi.Close()
+
+	if err := CSS.WriteCSS(fi, styles.Get("catpuccin-latte")); err != nil {
+		return err
+	}
+	fi.WriteString("@media (prefers-color-scheme: dark) {")
+	if err := CSS.WriteCSS(fi, styles.Get("catppuccin-mocha")); err != nil {
+		return err
+	}
+	fi.WriteString("}")
+	return nil
+}
+
+func main() {
+	if err := maine(); err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+}
I templates.go
diff --git a/templates.go b/templates.go
new file mode 100644
index 0000000000000000000000000000000000000000..8af412b693c9a8150dd0e116a5d93f31a59570d1
--- /dev/null
+++ b/templates.go
@@ -0,0 +1,119 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/BurntSushi/toml"
+	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
+	"github.com/yuin/goldmark"
+	emoji "github.com/yuin/goldmark-emoji"
+	highlighting "github.com/yuin/goldmark-highlighting/v2"
+	meta "github.com/yuin/goldmark-meta"
+	"github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	"github.com/yuin/goldmark/renderer/html"
+)
+
+// Articles by Category
+type Articles map[string][]Article
+
+type Article struct {
+	Content string
+	Meta
+}
+
+func (a Article) Slug() string {
+	slug := strings.NewReplacer(" ", "-").Replace(strings.ToLower(a.Meta.Title))
+	return fmt.Sprintf("%s/%s/", a.Meta.Date.Format("2006"), slug)
+}
+
+type Meta struct {
+	Title    string
+	Summary  string
+	Date     time.Time
+	Category string
+}
+
+type Author struct {
+	Name  string
+	Email string
+}
+
+func Parse(content string) (Article, error) {
+	lines := strings.Split(content, "\n")
+
+	start, end := -1, -1
+	for idx, line := range lines {
+		if strings.TrimSpace(line) == "" {
+			continue
+		}
+
+		if isTOMLSeparator(line) {
+			if start == -1 {
+				start = idx
+				continue
+			}
+			end = idx
+			break
+		}
+	}
+
+	var meta Meta
+	body := content
+	if start != -1 && end != -1 {
+		body = strings.Join(lines[end+1:], "\n")
+		if err := toml.Unmarshal([]byte(strings.Join(lines[start+1:end], "\n")), &meta); err != nil {
+			return Article{}, fmt.Errorf("could not parse frontmatter: %w", err)
+		}
+	}
+
+	var buf bytes.Buffer
+	err := Markdown.Convert([]byte(body), &buf)
+	if err != nil {
+		return Article{}, fmt.Errorf("could not convert article: %w", err)
+	}
+
+	return Article{
+		Content: buf.String(),
+		Meta:    meta,
+	}, nil
+}
+
+func isTOMLSeparator(line string) bool {
+	for _, char := range line {
+		if char != '+' {
+			return false
+		}
+	}
+	return true
+}
+
+var (
+	opts = []chromahtml.Option{
+		chromahtml.WithClasses(true),
+		chromahtml.WithAllClasses(true),
+		chromahtml.WithLineNumbers(true),
+	}
+	Markdown = goldmark.New(
+		goldmark.WithParserOptions(
+			parser.WithAutoHeadingID(),
+		),
+		goldmark.WithRendererOptions(
+			html.WithUnsafe(),
+		),
+		goldmark.WithExtensions(
+			extension.GFM,
+			meta.Meta,
+			emoji.Emoji,
+			highlighting.NewHighlighting(
+				highlighting.WithFormatOptions(
+					opts...,
+				),
+			),
+		),
+	)
+	CSS = chromahtml.New(opts...)
+)
I templates.templ
diff --git a/templates.templ b/templates.templ
new file mode 100644
index 0000000000000000000000000000000000000000..daee1e81f364b2c7d571ffe38b7e3eb24d24def0
--- /dev/null
+++ b/templates.templ
@@ -0,0 +1,45 @@
+package main
+
+templ baseTemplate(title, description string) {
+	<!DOCTYPE html>
+	<html>
+		<head>
+			<meta charset="UTF-8"/>
+			<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+			<title>{ title }</title>
+			<meta property="og:title" content={ title }/>
+			<meta property="og:description" content={ description }/>
+			<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css"/>
+			<link rel="stylesheet" href="/chroma.css"/>
+		</head>
+		<body>
+			{ children... }
+		</body>
+	</html>
+}
+
+templ IndexTemplate(articles Articles) {
+	@baseTemplate("jolheiser's blog", "Hahaha yes.....YES!") {
+		<main>
+			for category, articles := range articles {
+				<h2>{ category }</h2>
+				<ul>
+					for _, article := range articles {
+						<li><a href={ templ.SafeURL(article.Slug()) }>{ article.Title }</a></li>
+					}
+				</ul>
+			}
+		</main>
+	}
+}
+
+templ ArticleTemplate(article Article) {
+	@baseTemplate(article.Title, article.Summary) {
+		<header>
+			<h1>{ article.Title }</h1>
+			<h2>{ article.Date.Format("01/02/2006") }</h2>
+		</header>
+		<main>@templ.Raw(article.Content)</main>
+	}
+}
+
I templates_templ.go
diff --git a/templates_templ.go b/templates_templ.go
new file mode 100644
index 0000000000000000000000000000000000000000..eb1a5286ff88f24036184a633dcb98ac78aa66d5
--- /dev/null
+++ b/templates_templ.go
@@ -0,0 +1,219 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: 0.2.480
+package main
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+func baseTemplate(title, description string) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var1 == nil {
+			templ_7745c5c3_Var1 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<!doctype html><html><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		var templ_7745c5c3_Var2 string = title
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</title><meta property=\"og:title\" content=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(title))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><meta property=\"og:description\" content=\"")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(description))
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\"><link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css\"><link rel=\"stylesheet\" href=\"/chroma.css\"></head><body>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</body></html>")
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
+
+func IndexTemplate(articles Articles) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var3 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var3 == nil {
+			templ_7745c5c3_Var3 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		templ_7745c5c3_Var4 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+			templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+			if !templ_7745c5c3_IsBuffer {
+				templ_7745c5c3_Buffer = templ.GetBuffer()
+				defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<main>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			for category, articles := range articles {
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<h2>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				var templ_7745c5c3_Var5 string = category
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h2><ul>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+				for _, article := range articles {
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<li><a href=\"")
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					var templ_7745c5c3_Var6 templ.SafeURL = templ.SafeURL(article.Slug())
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(string(templ_7745c5c3_Var6)))
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("\">")
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					var templ_7745c5c3_Var7 string = article.Title
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</a></li>")
+					if templ_7745c5c3_Err != nil {
+						return templ_7745c5c3_Err
+					}
+				}
+				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</ul>")
+				if templ_7745c5c3_Err != nil {
+					return templ_7745c5c3_Err
+				}
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</main>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if !templ_7745c5c3_IsBuffer {
+				_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
+			}
+			return templ_7745c5c3_Err
+		})
+		templ_7745c5c3_Err = baseTemplate("jolheiser's blog", "Hahaha yes.....YES!").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}
+
+func ArticleTemplate(article Article) templ.Component {
+	return templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+		templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+		if !templ_7745c5c3_IsBuffer {
+			templ_7745c5c3_Buffer = templ.GetBuffer()
+			defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+		}
+		ctx = templ.InitializeContext(ctx)
+		templ_7745c5c3_Var8 := templ.GetChildren(ctx)
+		if templ_7745c5c3_Var8 == nil {
+			templ_7745c5c3_Var8 = templ.NopComponent
+		}
+		ctx = templ.ClearChildren(ctx)
+		templ_7745c5c3_Var9 := templ.ComponentFunc(func(ctx context.Context, templ_7745c5c3_W io.Writer) (templ_7745c5c3_Err error) {
+			templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templ_7745c5c3_W.(*bytes.Buffer)
+			if !templ_7745c5c3_IsBuffer {
+				templ_7745c5c3_Buffer = templ.GetBuffer()
+				defer templ.ReleaseBuffer(templ_7745c5c3_Buffer)
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("<header><h1>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var10 string = article.Title
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h1><h2>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			var templ_7745c5c3_Var11 string = article.Date.Format("01/02/2006")
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</h2></header><main>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			templ_7745c5c3_Err = templ.Raw(article.Content).Render(ctx, templ_7745c5c3_Buffer)
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("</main>")
+			if templ_7745c5c3_Err != nil {
+				return templ_7745c5c3_Err
+			}
+			if !templ_7745c5c3_IsBuffer {
+				_, templ_7745c5c3_Err = io.Copy(templ_7745c5c3_W, templ_7745c5c3_Buffer)
+			}
+			return templ_7745c5c3_Err
+		})
+		templ_7745c5c3_Err = baseTemplate(article.Title, article.Summary).Render(templ.WithChildren(ctx, templ_7745c5c3_Var9), templ_7745c5c3_Buffer)
+		if templ_7745c5c3_Err != nil {
+			return templ_7745c5c3_Err
+		}
+		if !templ_7745c5c3_IsBuffer {
+			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteTo(templ_7745c5c3_W)
+		}
+		return templ_7745c5c3_Err
+	})
+}