initial commit - added base, need to fix templ errors

This commit is contained in:
2025-08-17 10:58:31 +00:00
commit 59d82ae563
20 changed files with 1512 additions and 0 deletions

24
go.mod Normal file
View File

@@ -0,0 +1,24 @@
module git.valxntine.dev/valxntine/blog
go 1.25.0
require (
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/a-h/templ v0.3.943 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
golang.org/x/mod v0.26.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/tools v0.35.0 // indirect
)
tool github.com/a-h/templ/cmd/templ

35
go.sum Normal file
View File

@@ -0,0 +1,35 @@
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY=
github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=

156
main.go Normal file
View File

@@ -0,0 +1,156 @@
package main
import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"git.valxntine.dev/valxntine/blog/models"
)
func main() {
mux := http.NewServeMux()
fs := http.FileServer(http.Dir("./static/"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
mux.HandleFunc("/", handleHome)
mux.HandleFunc("/posts/", handlePost)
mux.HandleFunc("/tags/", handleTag)
handler := loggingMiddleware(mux)
log.Println("Serving on :8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s %s", r.Method, r.URL.Path, r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
func handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
posts := GetPaginatedPosts(page, 5)
total := GetTotalPages(5)
d := models.Data{
Title: "~/valxntine/blog",
Subtitle: "Thought from the Forge",
Posts: posts,
CurrentPage: page,
TotalPages: total,
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
err := templates.PostList(
d.Posts,
d.CurrentPage,
d.TotalPages,
).Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "text/html")
err := templates.HomePage(d).Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func handlePost(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/posts/")
if slug == "" {
http.NotFound(w, r)
return
}
post := GetPostBySlug(slug)
if post == nil {
http.NotFound(w, r)
return
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
err := templates.PostDetail(*post).Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "text/html")
// err := templates.Layout(post.Title + " - ~/valxntine/blog") {
// @templates.Header("~/valxntine/blog", "Thoughts from the Forge")
// @templates.Nav()
// <main>
// @templates.PostDetail(*post)
// </main>
// @templates.Footer()
// }.Render(r.Context(), w)
// if err != nil {
// http.Error(w, err.Error(), http.StatusInternalServerError)
// }
// }
}
func handleTag(w http.ResponseWriter, r *http.Request) {
tag := strings.TrimPrefix(r.URL.Path, "/tags/")
if tag == "" {
http.NotFound(w, r)
return
}
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
posts := GetPostsByTag(tag, page, 5)
total := GetTotalPagesByTag(tag, 5)
d := models.Data{
Title: "~/valxntine/blog",
Subtitle: fmt.Sprintf("Posts tagged with '%s'", tag),
Posts: posts,
CurrentPage: page,
TotalPages: total,
}
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html")
err := templates.PostList(d.Posts, d.CurrentPage, d.TotalPages).Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "text/html")
err := templates.HomePage(d).Render(r.Context(), w)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}

22
models/models.go Normal file
View File

@@ -0,0 +1,22 @@
package models
import "time"
type Post struct {
ID string
Title string
Slug string
Excerpt string
Content string
Tags []string
PublishedAt time.Time
WordCount int
}
type Data struct {
Title string
Subtitle string
Posts []Post
CurrentPage int
TotalPages int
}

370
posts.go Normal file
View File

@@ -0,0 +1,370 @@
package main
import (
"bufio"
"embed"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"git.valxntine.dev/valxntine/blog/models"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
)
//go:embed posts/*.md
var postsFS embed.FS
var (
postCache []models.Post
postCacheTime time.Time
cacheValid = false
)
type PostMeta struct {
Title string
Date string
Tags []string
Excerpt string
WordCount int
Draft bool
}
var md = goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extension.Table,
extension.Strikethrough,
extension.Linkify,
extension.TaskList,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithHardWraps(),
html.WithXHTML(),
html.WithUnsafe(),
),
)
func parseFrontmatter(content string) (PostMeta, string, error) {
lines := strings.Split(content, "\n")
if len(lines) < 2 || lines[0] != "---" {
return PostMeta{}, content, fmt.Errorf("no frontmatter found")
}
meta := PostMeta{}
var endID int
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
endID = i
break
}
}
if endID == 0 {
return meta, content, fmt.Errorf("frontmatter not closed")
}
for i := 1; i < endID; i++ {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
k := strings.TrimSpace(parts[0])
v := strings.TrimSpace(parts[1])
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
v = v[1 : len(v)-1]
}
switch strings.ToLower(k) {
case "title":
meta.Title = v
case "date":
meta.Date = v
case "excerpt":
meta.Excerpt = v
case "tags":
if v != "" {
tags := strings.Split(v, ",")
for _, t := range tags {
meta.Tags = append(meta.Tags, strings.TrimSpace(t))
}
}
case "draft":
meta.Draft = strings.ToLower(v) == "true"
}
}
body := strings.Join(lines[endID+1:], "\n")
return meta, body, nil
}
func generateSlug(fn string) string {
name := filepath.Base(fn)
name = strings.TrimSuffix(name, filepath.Ext(name))
if len(name) > 11 && name[4] == '-' && name[7] == '-' && name[10] == '-' {
name = name[11:]
}
return name
}
func countWords(c string) int {
sc := bufio.NewScanner(strings.NewReader(c))
sc.Split(bufio.ScanWords)
count := 0
for sc.Scan() {
count++
}
return count
}
func load() ([]models.Post, error) {
if cacheValid && time.Since(postCacheTime) < 10*time.Minute {
return postCache, nil
}
var posts []models.Post
entries, err := postsFS.ReadDir("posts")
if err != nil {
return nil, fmt.Errorf("failed to read posts dir: %w", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
c, err := postsFS.ReadFile(filepath.Join("posts", entry.Name()))
if err != nil {
continue
}
meta, body, err := parseFrontmatter(string(c))
if err != nil {
meta = PostMeta{
Title: strings.TrimSuffix(entry.Name(), ".md"),
Date: time.Now().Format("2006-01-02"),
}
body = string(c)
}
if meta.Draft {
continue
}
published, err := time.Parse("2006-01-02", meta.Date)
if err != nil {
published = time.Now()
}
var htmlC strings.Builder
if err := md.Convert([]byte(body), &htmlC); err != nil {
continue
}
excerpt := meta.Excerpt
if excerpt == "" {
plain := stripHTML(htmlC.String())
words := strings.Fields(plain)
if len(words) > 30 {
excerpt = strings.Join(words[:30], " ") + "..."
} else {
excerpt = plain
}
}
count := meta.WordCount
if count == 0 {
count = countWords(body)
}
slug := generateSlug(entry.Name())
post := models.Post{
ID: slug,
Title: meta.Title,
Slug: slug,
Excerpt: excerpt,
Content: htmlC.String(),
Tags: meta.Tags,
PublishedAt: published,
WordCount: count,
}
posts = append(posts, post)
}
sort.Slice(posts, func(i, j int) bool {
return posts[i].PublishedAt.After(posts[j].PublishedAt)
})
postCache = posts
postCacheTime = time.Now()
cacheValid = true
return posts, nil
}
func stripHTML(s string) string {
in := false
var sb strings.Builder
for _, r := range s {
if r == '<' {
in = true
continue
}
if r == '>' {
in = false
continue
}
if !in {
sb.WriteRune(r)
}
}
return sb.String()
}
func GetPaginatedPosts(page, perPage int) []models.Post {
posts, err := load()
if err != nil {
return []models.Post{}
}
start := (page - 1) * perPage
end := start + perPage
if start >= len(posts) {
return []models.Post{}
}
if end > len(posts) {
end = len(posts)
}
return posts[start:end]
}
func GetTotalPages(per int) int {
posts, err := load()
if err != nil {
return 1
}
return (len(posts) + per - 1) / per
}
func GetPostBySlug(slug string) *models.Post {
posts, err := load()
if err != nil {
return nil
}
for _, post := range posts {
if post.Slug == slug {
return &post
}
}
return nil
}
func GetPostsByTag(tag string, page, per int) []models.Post {
posts, err := load()
if err != nil {
return []models.Post{}
}
var filtered []models.Post
for _, post := range posts {
for _, t := range post.Tags {
if t == tag {
filtered = append(filtered, post)
break
}
}
}
start := (page - 1) * per
end := start + per
if start >= len(filtered) {
return []models.Post{}
}
if end > len(filtered) {
end = len(filtered)
}
return filtered[start:end]
}
func GetTotalPagesByTag(tag string, per int) int {
posts, err := load()
if err != nil {
return 1
}
count := 0
for _, post := range posts {
for _, t := range post.Tags {
if t == tag {
count++
break
}
}
}
return (count + per - 1) / per
}
func GetAllPosts() []models.Post {
posts, err := load()
if err != nil {
return []models.Post{}
}
return posts
}
func GetAllTags() []string {
posts := GetAllPosts()
if len(posts) == 0 {
return []string{}
}
set := make(map[string]bool)
for _, post := range posts {
for _, tag := range post.Tags {
set[tag] = true
}
}
var tags []string
for tag := range set {
tags = append(tags, tag)
}
sort.Strings(tags)
return tags
}

32
posts/hello.md Normal file
View File

@@ -0,0 +1,32 @@
---
title: "Hello, Forge"
date: "2025-08-17"
excerpt: "The Hammer Strikes..."
draft: false
---
# Hello, Forge
Welcome.
This is my home.
A place to show you all the things, write some code, and explore new features of Go and other languages I enjoy writing.
```go
type Valentine struct {
Age int
Hobbies []string
Job string
}
func main() {
valentine := Valentine{
Age: 32,
Hobbies: []string{"Lifting heavy things", "Walking Dwight", "Writing Go"},
Job: "Senior Software Engineer",
}
fmt.Sprintf("%+v\n", valentine)
}
```

402
static/style.css Normal file
View File

@@ -0,0 +1,402 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Consolas', 'Courier New', monospace;
background-color: #fafafa;
color: #2d3748;
line-height: 1.6;
padding: 20px;
max-width: 720px;
margin: 0 auto;
}
.header {
border: 2px solid #4a5568;
background-color: #f7fafc;
padding: 24px;
margin-bottom: 32px;
text-align: center;
border-radius: 4px;
}
.header h1 {
font-size: 32px;
font-weight: 600;
margin-bottom: 8px;
color: #1a202c;
}
.header .subtitle {
font-size: 16px;
color: #718096;
font-weight: 400;
}
.nav {
background-color: #edf2f7;
border: 1px solid #cbd5e0;
padding: 16px;
margin-bottom: 24px;
font-size: 15px;
border-radius: 4px;
}
.nav a {
color: #3182ce;
text-decoration: none;
margin-right: 20px;
padding: 4px 8px;
border-radius: 3px;
transition: all 0.2s ease;
}
.nav a:visited {
color: #805ad5;
}
.nav a:hover {
background-color: #bee3f8;
text-decoration: underline;
}
.blog-posts {
list-style: none;
}
.blog-post {
border: 1px solid #e2e8f0;
background-color: #ffffff;
margin-bottom: 20px;
padding: 20px;
border-radius: 6px;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.blog-post:hover {
border-color: #cbd5e0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.post-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 10px;
}
.post-title a {
color: #2b6cb0;
text-decoration: none;
}
.post-title a:visited {
color: #805ad5;
}
.post-title a:hover {
text-decoration: underline;
color: #2c5282;
}
.post-meta {
font-size: 13px;
color: #718096;
margin-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 8px;
font-weight: 400;
}
.post-excerpt {
font-size: 15px;
margin-bottom: 10px;
color: #4a5568;
}
.read-more {
font-size: 13px;
}
.read-more a {
color: #3182ce;
text-decoration: none;
font-weight: 500;
padding: 2px 4px;
border-radius: 2px;
transition: all 0.2s ease;
}
.read-more a:hover {
background-color: #ebf8ff;
text-decoration: underline;
}
.footer {
margin-top: 48px;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
text-align: center;
font-size: 13px;
color: #718096;
}
.footer a {
color: #3182ce;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Mobile responsive */
@media (max-width: 600px) {
body {
padding: 16px;
}
.header {
padding: 20px;
}
.header h1 {
font-size: 26px;
}
.header .subtitle {
font-size: 14px;
}
.nav {
font-size: 14px;
padding: 12px;
}
.nav a {
display: block;
margin-bottom: 8px;
margin-right: 0;
padding: 8px 12px;
}
.blog-post {
padding: 16px;
}
.pagination {
flex-direction: column;
gap: 12px;
}
.post-detail {
padding: 20px;
}
.post-detail .post-title {
font-size: 24px;
}
}
@media (max-width: 400px) {
.header h1 {
font-size: 22px;
}
.post-title {
font-size: 16px;
}
.post-excerpt {
font-size: 14px;
}
}
/* Subtle animations and modern touches */
.blink {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.loading {
opacity: 0.7;
pointer-events: none;
transition: opacity 0.2s ease;
}
.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
margin: -10px 0 0 -10px;
border: 2px solid #e2e8f0;
border-top: 2px solid #3182ce;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Pagination Styles */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 32px;
padding: 20px;
background-color: #f7fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
}
.pagination a {
color: #3182ce;
text-decoration: none;
padding: 8px 16px;
border-radius: 4px;
transition: all 0.2s ease;
font-weight: 500;
}
.pagination a:hover {
background-color: #bee3f8;
text-decoration: underline;
}
.page-info {
color: #718096;
font-weight: 500;
}
/* Post Detail Styles */
.post-detail {
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.post-detail .post-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 16px;
color: #1a202c;
}
.post-detail .post-meta {
font-size: 14px;
color: #718096;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e2e8f0;
}
.post-content {
font-size: 16px;
line-height: 1.7;
color: #2d3748;
}
.post-content h2 {
font-size: 24px;
font-weight: 600;
margin: 32px 0 16px 0;
color: #1a202c;
}
.post-content h3 {
font-size: 20px;
font-weight: 600;
margin: 24px 0 12px 0;
color: #1a202c;
}
.post-content p {
margin-bottom: 16px;
}
.post-content code {
background-color: #f7fafc;
padding: 2px 6px;
border-radius: 3px;
font-size: 14px;
border: 1px solid #e2e8f0;
}
.post-content pre {
background-color: #1a202c;
color: #f7fafc;
padding: 16px;
border-radius: 6px;
overflow-x: auto;
margin: 16px 0;
}
.post-content pre code {
background: none;
border: none;
padding: 0;
color: inherit;
}
.post-footer {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e2e8f0;
}
.post-footer a {
color: #3182ce;
text-decoration: none;
font-weight: 500;
padding: 8px 12px;
border-radius: 4px;
transition: all 0.2s ease;
}
.post-footer a:hover {
background-color: #ebf8ff;
text-decoration: underline;
}
/* Tag Links */
a[href^="/tags/"] {
background-color: #ebf8ff;
color: #2b6cb0;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
text-decoration: none;
font-weight: 500;
transition: all 0.2s ease;
}
a[href^="/tags/"]:hover {
background-color: #bee3f8;
text-decoration: none;
}
/* HTMX Transitions */
.htmx-settling {
opacity: 0;
}
.htmx-swapping {
opacity: 0;
transition: opacity 0.2s ease;
}

11
templates/footer.templ Normal file
View File

@@ -0,0 +1,11 @@
package templates
templ Footer() {
<footer class="footer">
<p>
© 2025 Valentine Bott | Built with Go + Templ + HTMX |
<a href="https://git.valxntine.dev/valxntine/blog">Source Code</a> |
Last updated: <span class="blink">●</span> Now-ish
</p>
</footer>
}

40
templates/footer_templ.go Normal file
View File

@@ -0,0 +1,40 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Footer() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
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 = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<footer class=\"footer\"><p>© 2025 Valentine Bott | Built with Go + Templ + HTMX | <a href=\"https://git.valxntine.dev/valxntine/blog\">Source Code</a> | Last updated: <span class=\"blink\">●</span> Now-ish</p></footer>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

8
templates/header.templ Normal file
View File

@@ -0,0 +1,8 @@
package templates
templ Header(title, subtitle string) {
<div class="header">
<h1>{ title }</h1>
<div class="subtitle">{ subtitle }</div>
</div>
}

66
templates/header_templ.go Normal file
View File

@@ -0,0 +1,66 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Header(title, subtitle string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
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 = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"header\"><h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 5, Col: 15}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h1><div class=\"subtitle\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(subtitle)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 6, Col: 36}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

12
templates/home.templ Normal file
View File

@@ -0,0 +1,12 @@
package templates
templ HomePage(data models.Data) {
@Layout(data.Title) {
@Header(data.Title, data.Subtitle)
@Nav()
<main>
@PostList(data.Posts, data.CurrentPage, data.TotalPages)
</main>
@Footer()
}
}

82
templates/home_templ.go Normal file
View File

@@ -0,0 +1,82 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func HomePage(data models.Data) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
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_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = Header(data.Title, data.Subtitle).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, " ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Nav().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " <main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = PostList(data.Posts, data.CurrentPage, data.TotalPages).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</main>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Footer().Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = Layout(data.Title).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

25
templates/layout.templ Normal file
View File

@@ -0,0 +1,25 @@
package templates
templ Layout(title string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
</head>
<body>
{ children... }
<script>
document.body.addEventListener('htmx:beforeRequest', function(e) {
e.detail.elt.classList.add('loading');
});
document.body.addEventListener('htmx:afterRequest', function(e) {
e.detail.elt.classList.remove('loading');
});
</script>
</body>
</html>
}

61
templates/layout_templ.go Normal file
View File

@@ -0,0 +1,61 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Layout(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
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 = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><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
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/layout.templ`, Line: 9, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><script src=\"https://unpkg.com/htmx.org@1.9.6\"></script></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 = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<script>\n document.body.addEventListener('htmx:beforeRequest', function(e) {\n e.detail.elt.classList.add('loading');\n });\n\n document.body.addEventListener('htmx:afterRequest', function(e) {\n e.detail.elt.classList.remove('loading');\n });\n </script></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

11
templates/nav.templ Normal file
View File

@@ -0,0 +1,11 @@
package templates
templ Nav() {
<nav class="nav">
<a href="/" hx-get="/" hx-target="main" hx-push-url="true">home</a>
<a href="/tags" hx-get="/tags" hx-target="main" hx-push-url="true">tags</a>
<a href="/about" hx-get="/about" hx-target="main" hx-push-url="true">about</a>
<a href="/archive" hx-get="/archive" hx-target="main" hx-push-url="true">archive</a>
<a href="mailto:valentine.bott@gmail.com">Contact Me</a>
</nav>
}

40
templates/nav_templ.go Normal file
View File

@@ -0,0 +1,40 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.943
package templates
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Nav() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
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 = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<nav class=\"nav\"><a href=\"/\" hx-get=\"/\" hx-target=\"main\" hx-push-url=\"true\">home</a> <a href=\"/tags\" hx-get=\"/tags\" hx-target=\"main\" hx-push-url=\"true\">tags</a> <a href=\"/about\" hx-get=\"/about\" hx-target=\"main\" hx-push-url=\"true\">about</a> <a href=\"/archive\" hx-get=\"/archive\" hx-target=\"main\" hx-push-url=\"true\">archive</a> <a href=\"mailto:valentine.bott@gmail.com\">Contact Me</a></nav>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate

View File

@@ -0,0 +1,29 @@
package templates
import "fmt"
templ Pagination(currentPage, totalPages int) {
<div class="pagination">
if currentPage > 1 {
<a href={ templ.URL(fmt.Sprintf("/?page=%d", currentPage-1)) }
hx-get={ fmt.Sprintf("/?page=%d", currentPage-1) }
hx-target="#post-list"
hx-swap="outerHTML">
<- Previous
</a>
}
<span class="page-info">
Page { fmt.Sprintf("%d", currentPage) }
</span>
if currentPage < totalPages {
<a href={ templ.URL(fmt.Sprintf("/?page=%d", currentPage+1)) }
hx-get={ fmt.Sprintf("/?page=%d", currentPage+1) }
hx-target="#post-list"
hx-swap="outerHTML">
Next ->
</a>
}
</div>
}

29
templates/post.templ Normal file
View File

@@ -0,0 +1,29 @@
package templates
templ PostDetail(post models.Post) {
<article class="post-detail">
<h1 class="post-title">{ post.Title }</h1>
<div class="post-meta">
Published: { post.PublishedAt.Format("January 2, 2006") } |
Tags:
for i, tag := range post.Tags {
if i > 0 {
,
}
<a href={ templ.URL("/tags/" + tag) }>{ tag }</a>
}
| { fmt.Sprintf("%d", post.WordCount) } words
</div>
<div class="post-content">
{ templ.Raw(post.Content) }
</div>
<div class="post-footer">
<a href="/"
hx-get="/"
hx-target="main"
hx-push-url="true">
<- Back to Posts
</a>
</div>
</article>
}

57
templates/posts.templ Normal file
View File

@@ -0,0 +1,57 @@
package templates
import "time"
templ PostList(posts []models.Post, page, totalPages int) {
<div id="post-list">
<ul class="blog-posts">
for _, post := range posts {
@PostListItem(post)
}
</ul>
if totalPages > 1 {
@Pagination(page, totalPages)
}
</div>
}
templ PostListItem(post models.Post) {
<li class="blog-post">
<div class="post-title">
<a href={ templ.URL("/posts/" + post.Slug) }
hx-get={ "/posts/" + post.Slug }
hx-target="main"
hx-push-url="true">
{ post.Title }
</a>
</div>
<div class="post-meta">
Posted: { post.PublishedAt.Format("2006-01-02") } |
Tags:
for i, tag := range post.Tags {
if i > 0 {
,
}
<a href={ templ.URL("/tags/" + tag) }
hx-get={ "/tags/" + tag }
hx-target="main"
hx-push-url="true">
{ tag }
</a>
}
| Words: { fmt.Sprintf("%d", post.WordCount) }
</div>
<div class="post-excerpt">
{ post.Excerpt }
</div>
<div class="read-more">
<a href={ templ.URL("/posts/" + post.Slug) }
hx-get={ "/posts/" + post.Slug }
hx-target="main"
hx-push-url=true>
[Read more...]
</a>
</div>
</li>
}