initial commit - added base, need to fix templ errors
This commit is contained in:
24
go.mod
Normal file
24
go.mod
Normal 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
35
go.sum
Normal 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
156
main.go
Normal 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
22
models/models.go
Normal 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
370
posts.go
Normal 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
32
posts/hello.md
Normal 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
402
static/style.css
Normal 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
11
templates/footer.templ
Normal 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
40
templates/footer_templ.go
Normal 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
8
templates/header.templ
Normal 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
66
templates/header_templ.go
Normal 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
12
templates/home.templ
Normal 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
82
templates/home_templ.go
Normal 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
25
templates/layout.templ
Normal 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
61
templates/layout_templ.go
Normal 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
11
templates/nav.templ
Normal 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
40
templates/nav_templ.go
Normal 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
|
29
templates/pagination.templ
Normal file
29
templates/pagination.templ
Normal 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
29
templates/post.templ
Normal 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
57
templates/posts.templ
Normal 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>
|
||||
}
|
Reference in New Issue
Block a user