diff --git a/assets/page.html b/assets/page.html
index a1dc96a..18cb76c 100644
--- a/assets/page.html
+++ b/assets/page.html
@@ -49,10 +49,10 @@
{{if .Content}}
{{.Content}}
{{end}}
- {{if .DiaryContent}}
- {{.DiaryContent}}
+ {{if .SpecialContent}}
+ {{.SpecialContent}}
{{end}}
- {{if or .Content .DiaryContent}}
+ {{if or .Content .SpecialContent}}
{{end}}
{{if .Entries}}
@@ -67,7 +67,7 @@
{{end}}
{{else if not .Content}}
- {{if not .DiaryContent}}
+ {{if not .SpecialContent}}
Empty folder — [CREATE]
{{end}}
{{end}}
diff --git a/diary.go b/diary.go
new file mode 100644
index 0000000..a02d534
--- /dev/null
+++ b/diary.go
@@ -0,0 +1,309 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "log"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+func init() {
+ pageTypeHandlers = append(pageTypeHandlers, &diaryHandler{})
+}
+
+type diaryHandler struct{}
+
+func (d *diaryHandler) handle(root, fsPath, urlPath string) *specialPage {
+ depth, ok := findDiaryContext(root, fsPath)
+ if !ok || depth == 0 {
+ return nil
+ }
+ var content template.HTML
+ switch depth {
+ case 1:
+ content = renderDiaryYear(fsPath, urlPath)
+ case 2:
+ content = renderDiaryMonth(fsPath, urlPath)
+ case 3:
+ content = renderDiaryDay(fsPath, urlPath)
+ }
+ return &specialPage{Content: content, SuppressListing: true}
+}
+
+// findDiaryContext walks up from fsPath toward root looking for a
+// .page-settings file with type=diary. Returns the depth of fsPath
+// relative to the diary root, and whether one was found.
+// depth=0 means fsPath itself is the diary root.
+func findDiaryContext(root, fsPath string) (int, bool) {
+ current := fsPath
+ for depth := 0; ; depth++ {
+ s := readPageSettings(current)
+ if s != nil && s.Type == "diary" {
+ return depth, true
+ }
+ if current == root {
+ break
+ }
+ parent := filepath.Dir(current)
+ if parent == current {
+ break
+ }
+ current = parent
+ }
+ return 0, false
+}
+
+// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
+type diaryPhoto struct {
+ Date time.Time
+ Name string
+ URL string
+}
+
+type diaryMonthSummary struct {
+ Name string
+ URL string
+ PhotoCount int
+}
+
+type diaryDaySection struct {
+ Heading string
+ URL string
+ Content template.HTML
+ Photos []diaryPhoto
+}
+
+type diaryYearData struct{ Months []diaryMonthSummary }
+type diaryMonthData struct{ Days []diaryDaySection }
+type diaryDayData struct{ Photos []diaryPhoto }
+
+var diaryYearTmpl = template.Must(template.New("diary-year").Parse(
+ `{{range .Months}}{{.Name}}{{if .PhotoCount}} ({{.PhotoCount}} photos){{end}}
{{end}}`,
+))
+
+var diaryMonthTmpl = template.Must(template.New("diary-month").Parse(
+ `{{range .Days}}{{if .URL}}{{.Heading}}{{else}}{{.Heading}}{{end}}
{{if .Content}}
{{.Content}}
{{end}}{{if .Photos}}
{{range .Photos}}

{{end}}
{{end}}
{{end}}`,
+))
+
+var diaryDayTmpl = template.Must(template.New("diary-day").Parse(
+ `{{if .Photos}}{{range .Photos}}

{{end}}
{{end}}`,
+))
+
+var photoExts = map[string]bool{
+ ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
+}
+
+// yearPhotos returns all photos in yearFsPath whose filename starts with
+// a YYYY-MM-DD date prefix.
+func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
+ entries, err := os.ReadDir(yearFsPath)
+ if err != nil {
+ return nil
+ }
+ var photos []diaryPhoto
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ name := e.Name()
+ if !photoExts[strings.ToLower(filepath.Ext(name))] {
+ continue
+ }
+ if len(name) < 10 {
+ continue
+ }
+ t, err := time.Parse("2006-01-02", name[:10])
+ if err != nil {
+ continue
+ }
+ photos = append(photos, diaryPhoto{
+ Date: t,
+ Name: name,
+ URL: path.Join(yearURLPath, url.PathEscape(name)),
+ })
+ }
+ return photos
+}
+
+// renderDiaryYear renders month sections with photo counts for a year folder.
+func renderDiaryYear(fsPath, urlPath string) template.HTML {
+ year, err := strconv.Atoi(filepath.Base(fsPath))
+ if err != nil {
+ return ""
+ }
+
+ photos := yearPhotos(fsPath, urlPath)
+
+ entries, err := os.ReadDir(fsPath)
+ if err != nil {
+ return ""
+ }
+
+ var months []diaryMonthSummary
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ monthNum, err := strconv.Atoi(e.Name())
+ if err != nil || monthNum < 1 || monthNum > 12 {
+ continue
+ }
+ count := 0
+ for _, p := range photos {
+ if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
+ count++
+ }
+ }
+ monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
+ months = append(months, diaryMonthSummary{
+ Name: monthDate.Format("January 2006"),
+ URL: path.Join(urlPath, e.Name()) + "/",
+ PhotoCount: count,
+ })
+ }
+
+ var buf bytes.Buffer
+ if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months}); err != nil {
+ log.Printf("diary year template: %v", err)
+ return ""
+ }
+ return template.HTML(buf.String())
+}
+
+// renderDiaryMonth renders a section per day, each with its markdown content
+// and photos sourced from the parent year folder.
+func renderDiaryMonth(fsPath, urlPath string) template.HTML {
+ yearFsPath := filepath.Dir(fsPath)
+ yearURLPath := parentURL(urlPath)
+
+ year, err := strconv.Atoi(filepath.Base(yearFsPath))
+ if err != nil {
+ return ""
+ }
+ monthNum, err := strconv.Atoi(filepath.Base(fsPath))
+ if err != nil || monthNum < 1 || monthNum > 12 {
+ return ""
+ }
+
+ allPhotos := yearPhotos(yearFsPath, yearURLPath)
+ var monthPhotos []diaryPhoto
+ for _, p := range allPhotos {
+ if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
+ monthPhotos = append(monthPhotos, p)
+ }
+ }
+
+ // Collect day numbers from subdirectories and from photo filenames.
+ daySet := map[int]bool{}
+ dayDirs := map[int]string{} // day number → actual directory name
+ entries, _ := os.ReadDir(fsPath)
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ d, err := strconv.Atoi(e.Name())
+ if err != nil || d < 1 || d > 31 {
+ continue
+ }
+ daySet[d] = true
+ dayDirs[d] = e.Name()
+ }
+ for _, p := range monthPhotos {
+ daySet[p.Date.Day()] = true
+ }
+
+ days := make([]int, 0, len(daySet))
+ for d := range daySet {
+ days = append(days, d)
+ }
+ sort.Ints(days)
+
+ var sections []diaryDaySection
+ for _, dayNum := range days {
+ date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
+
+ heading := date.Format("Monday, January 2")
+ dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
+ var content template.HTML
+ if dirName, ok := dayDirs[dayNum]; ok {
+ dayURL = path.Join(urlPath, dirName) + "/"
+ dayFsPath := filepath.Join(fsPath, dirName)
+ if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 {
+ if h := extractFirstHeading(raw); h != "" {
+ heading = h
+ raw = stripFirstHeading(raw)
+ }
+ content = renderMarkdown(raw)
+ }
+ }
+
+ var photos []diaryPhoto
+ for _, p := range monthPhotos {
+ if p.Date.Day() == dayNum {
+ photos = append(photos, p)
+ }
+ }
+
+ sections = append(sections, diaryDaySection{
+ Heading: heading,
+ URL: dayURL,
+ Content: content,
+ Photos: photos,
+ })
+ }
+
+ var buf bytes.Buffer
+ if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil {
+ log.Printf("diary month template: %v", err)
+ return ""
+ }
+ return template.HTML(buf.String())
+}
+
+// renderDiaryDay renders the photo grid for a single day, sourcing photos
+// from the grandparent year folder.
+func renderDiaryDay(fsPath, urlPath string) template.HTML {
+ monthFsPath := filepath.Dir(fsPath)
+ yearFsPath := filepath.Dir(monthFsPath)
+ yearURLPath := parentURL(parentURL(urlPath))
+
+ year, err := strconv.Atoi(filepath.Base(yearFsPath))
+ if err != nil {
+ return ""
+ }
+ monthNum, err := strconv.Atoi(filepath.Base(monthFsPath))
+ if err != nil {
+ return ""
+ }
+ dayNum, err := strconv.Atoi(filepath.Base(fsPath))
+ if err != nil {
+ return ""
+ }
+
+ allPhotos := yearPhotos(yearFsPath, yearURLPath)
+ var photos []diaryPhoto
+ for _, p := range allPhotos {
+ if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum {
+ photos = append(photos, p)
+ }
+ }
+
+ if len(photos) == 0 {
+ return ""
+ }
+
+ var buf bytes.Buffer
+ if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
+ log.Printf("diary day template: %v", err)
+ return ""
+ }
+ return template.HTML(buf.String())
+}
diff --git a/main.go b/main.go
index 71fc2e3..3ea5363 100644
--- a/main.go
+++ b/main.go
@@ -1,99 +1,39 @@
package main
import (
- "bytes"
"embed"
"flag"
- "fmt"
"html/template"
"io/fs"
"log"
"net/http"
- "net/url"
"os"
"path"
"path/filepath"
- "sort"
- "strconv"
"strings"
- "time"
-
- "github.com/yuin/goldmark"
- "github.com/yuin/goldmark/extension"
- "github.com/yuin/goldmark/parser"
- "github.com/yuin/goldmark/renderer/html"
)
//go:embed assets/*
var assets embed.FS
-var md = goldmark.New(
- goldmark.WithExtensions(extension.GFM, extension.Table),
- goldmark.WithParserOptions(parser.WithAutoHeadingID()),
- goldmark.WithRendererOptions(html.WithUnsafe()),
-)
-
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
-// Diary sub-templates — executed into a buffer and injected as DiaryContent.
-var diaryYearTmpl = template.Must(template.New("diary-year").Parse(
- `{{range .Months}}{{.Name}}{{if .PhotoCount}} ({{.PhotoCount}} photos){{end}}
{{end}}`,
-))
-
-var diaryMonthTmpl = template.Must(template.New("diary-month").Parse(
- `{{range .Days}}{{if .URL}}{{.Heading}}{{else}}{{.Heading}}{{end}}
{{if .Content}}
{{.Content}}
{{end}}{{if .Photos}}
{{range .Photos}}

{{end}}
{{end}}
{{end}}`,
-))
-
-var diaryDayTmpl = template.Must(template.New("diary-day").Parse(
- `{{if .Photos}}{{range .Photos}}

{{end}}
{{end}}`,
-))
-
-type crumb struct{ Name, URL string }
-type entry struct {
- Icon template.HTML
- Name, URL, Meta string
+// specialPage is the result returned by a pageTypeHandler.
+// Content is injected into the page after the standard markdown content.
+// SuppressListing hides the default file/folder listing.
+type specialPage struct {
+ Content template.HTML
+ SuppressListing bool
}
-type pageData struct {
- Title string
- Crumbs []crumb
- CanEdit bool
- EditMode bool
- PostURL string
- RawContent string
- Content template.HTML
- Entries []entry
- DiaryContent template.HTML
+// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
+// handle returns nil when the handler does not apply to the given path.
+type pageTypeHandler interface {
+ handle(root, fsPath, urlPath string) *specialPage
}
-// pageSettings holds the parsed contents of a .page-settings file.
-type pageSettings struct {
- Type string
-}
-
-// diaryPhoto is a photo file whose name starts with a YYYY-MM-DD date prefix.
-type diaryPhoto struct {
- Date time.Time
- Name string
- URL string
-}
-
-type diaryMonthSummary struct {
- Name string
- URL string
- PhotoCount int
-}
-
-type diaryDaySection struct {
- Heading string
- URL string
- Content template.HTML
- Photos []diaryPhoto
-}
-
-type diaryYearData struct{ Months []diaryMonthSummary }
-type diaryMonthData struct{ Days []diaryDaySection }
-type diaryDayData struct{ Photos []diaryPhoto }
+// pageTypeHandlers is the registry. Each type registers itself via init().
+var pageTypeHandlers []pageTypeHandler
func main() {
addr := flag.String("addr", ":8080", "listen address")
@@ -135,7 +75,6 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
urlPath := path.Clean("/" + r.URL.Path)
-
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
// Security: ensure the resolved path stays within root.
@@ -181,41 +120,40 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
rendered = renderMarkdown(rawMD)
}
- diaryDepth, isDiary := h.findDiaryContext(fsPath)
-
- // For diary sub-pages (depth > 0) the diary content replaces the file listing.
- var entries []entry
- if !editMode && (!isDiary || diaryDepth == 0) {
- entries = listEntries(fsPath, urlPath)
- }
-
- var diaryContent template.HTML
- if !editMode && isDiary {
- switch diaryDepth {
- case 1:
- diaryContent = h.renderDiaryYear(fsPath, urlPath)
- case 2:
- diaryContent = h.renderDiaryMonth(fsPath, urlPath)
- case 3:
- diaryContent = h.renderDiaryDay(fsPath, urlPath)
+ var special *specialPage
+ if !editMode {
+ for _, ph := range pageTypeHandlers {
+ if special = ph.handle(h.root, fsPath, urlPath); special != nil {
+ break
+ }
}
}
+ var entries []entry
+ if !editMode && (special == nil || !special.SuppressListing) {
+ entries = listEntries(fsPath, urlPath)
+ }
+
title := pageTitle(urlPath)
- if h := extractFirstHeading(rawMD); h != "" {
- title = h
+ if heading := extractFirstHeading(rawMD); heading != "" {
+ title = heading
+ }
+
+ var specialContent template.HTML
+ if special != nil {
+ specialContent = special.Content
}
data := pageData{
- Title: title,
- Crumbs: buildCrumbs(urlPath),
- CanEdit: true,
- EditMode: editMode,
- PostURL: urlPath,
- RawContent: string(rawMD),
- Content: rendered,
- Entries: entries,
- DiaryContent: diaryContent,
+ Title: title,
+ Crumbs: buildCrumbs(urlPath),
+ CanEdit: true,
+ EditMode: editMode,
+ PostURL: urlPath,
+ RawContent: string(rawMD),
+ Content: rendered,
+ Entries: entries,
+ SpecialContent: specialContent,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -274,392 +212,3 @@ func readPageSettings(dir string) *pageSettings {
}
return s
}
-
-// findDiaryContext walks up from fsPath toward h.root looking for a
-// .page-settings file with type=diary. Returns the depth of fsPath
-// relative to the diary root, and whether a diary root was found at all.
-// depth=0 means fsPath itself is the diary root.
-func (h *handler) findDiaryContext(fsPath string) (int, bool) {
- current := fsPath
- for depth := 0; ; depth++ {
- s := readPageSettings(current)
- if s != nil && s.Type == "diary" {
- return depth, true
- }
- if current == h.root {
- break
- }
- parent := filepath.Dir(current)
- if parent == current {
- break
- }
- current = parent
- }
- return 0, false
-}
-
-// yearPhotos returns all photos in yearFsPath whose filename starts with
-// a YYYY-MM-DD date prefix. yearURLPath is the corresponding URL prefix.
-var photoExts = map[string]bool{
- ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
-}
-
-func yearPhotos(yearFsPath, yearURLPath string) []diaryPhoto {
- entries, err := os.ReadDir(yearFsPath)
- if err != nil {
- return nil
- }
- var photos []diaryPhoto
- for _, e := range entries {
- if e.IsDir() {
- continue
- }
- name := e.Name()
- if !photoExts[strings.ToLower(filepath.Ext(name))] {
- continue
- }
- if len(name) < 10 {
- continue
- }
- t, err := time.Parse("2006-01-02", name[:10])
- if err != nil {
- continue
- }
- photos = append(photos, diaryPhoto{
- Date: t,
- Name: name,
- URL: path.Join(yearURLPath, url.PathEscape(name)),
- })
- }
- return photos
-}
-
-// extractFirstHeading returns the text of the first ATX heading in raw markdown,
-// or an empty string if none is found.
-func extractFirstHeading(raw []byte) string {
- for _, line := range strings.SplitN(string(raw), "\n", 50) {
- trimmed := strings.TrimSpace(line)
- if !strings.HasPrefix(trimmed, "#") {
- continue
- }
- text := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
- if text != "" {
- return text
- }
- }
- return ""
-}
-
-// stripFirstHeading removes the first ATX heading line from raw markdown.
-func stripFirstHeading(raw []byte) []byte {
- lines := strings.Split(string(raw), "\n")
- for i, line := range lines {
- if strings.HasPrefix(strings.TrimSpace(line), "#") {
- result := append(lines[:i:i], lines[i+1:]...)
- return []byte(strings.TrimLeft(strings.Join(result, "\n"), "\n"))
- }
- }
- return raw
-}
-
-// renderMarkdown converts raw markdown to trusted HTML.
-func renderMarkdown(raw []byte) template.HTML {
- var buf bytes.Buffer
- if err := md.Convert(raw, &buf); err != nil {
- return ""
- }
- return template.HTML(buf.String())
-}
-
-// parentURL returns the parent URL of a slash-terminated URL path.
-func parentURL(urlPath string) string {
- parent := path.Dir(strings.TrimSuffix(urlPath, "/"))
- if parent == "." || parent == "/" {
- return "/"
- }
- return parent + "/"
-}
-
-// renderDiaryYear renders month sections with photo counts for a year folder.
-func (h *handler) renderDiaryYear(fsPath, urlPath string) template.HTML {
- yearStr := filepath.Base(fsPath)
- year, err := strconv.Atoi(yearStr)
- if err != nil {
- return ""
- }
-
- photos := yearPhotos(fsPath, urlPath)
-
- entries, err := os.ReadDir(fsPath)
- if err != nil {
- return ""
- }
-
- var months []diaryMonthSummary
- for _, e := range entries {
- if !e.IsDir() {
- continue
- }
- monthNum, err := strconv.Atoi(e.Name())
- if err != nil || monthNum < 1 || monthNum > 12 {
- continue
- }
- count := 0
- for _, p := range photos {
- if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
- count++
- }
- }
- monthDate := time.Date(year, time.Month(monthNum), 1, 0, 0, 0, 0, time.UTC)
- months = append(months, diaryMonthSummary{
- Name: monthDate.Format("January 2006"),
- URL: path.Join(urlPath, e.Name()) + "/",
- PhotoCount: count,
- })
- }
-
- var buf bytes.Buffer
- if err := diaryYearTmpl.Execute(&buf, diaryYearData{Months: months}); err != nil {
- log.Printf("diary year template: %v", err)
- return ""
- }
- return template.HTML(buf.String())
-}
-
-// renderDiaryMonth renders a section per day, each with its markdown content
-// and photos sourced from the parent year folder.
-func (h *handler) renderDiaryMonth(fsPath, urlPath string) template.HTML {
- yearFsPath := filepath.Dir(fsPath)
- yearURLPath := parentURL(urlPath)
-
- year, err := strconv.Atoi(filepath.Base(yearFsPath))
- if err != nil {
- return ""
- }
- monthNum, err := strconv.Atoi(filepath.Base(fsPath))
- if err != nil || monthNum < 1 || monthNum > 12 {
- return ""
- }
-
- allPhotos := yearPhotos(yearFsPath, yearURLPath)
- var monthPhotos []diaryPhoto
- for _, p := range allPhotos {
- if p.Date.Year() == year && int(p.Date.Month()) == monthNum {
- monthPhotos = append(monthPhotos, p)
- }
- }
-
- // Collect day numbers from subdirectories and from photo filenames.
- daySet := map[int]bool{}
- dayDirs := map[int]string{} // day number → actual directory name
- entries, _ := os.ReadDir(fsPath)
- for _, e := range entries {
- if !e.IsDir() {
- continue
- }
- d, err := strconv.Atoi(e.Name())
- if err != nil || d < 1 || d > 31 {
- continue
- }
- daySet[d] = true
- dayDirs[d] = e.Name()
- }
- for _, p := range monthPhotos {
- daySet[p.Date.Day()] = true
- }
-
- days := make([]int, 0, len(daySet))
- for d := range daySet {
- days = append(days, d)
- }
- sort.Ints(days)
-
- var sections []diaryDaySection
- for _, dayNum := range days {
- date := time.Date(year, time.Month(monthNum), dayNum, 0, 0, 0, 0, time.UTC)
-
- heading := date.Format("Monday, January 2")
- dayURL := path.Join(urlPath, fmt.Sprintf("%02d", dayNum)) + "/"
- var content template.HTML
- if dirName, ok := dayDirs[dayNum]; ok {
- dayURL = path.Join(urlPath, dirName) + "/"
- dayFsPath := filepath.Join(fsPath, dirName)
- if raw, err := os.ReadFile(filepath.Join(dayFsPath, "index.md")); err == nil && len(raw) > 0 {
- if h := extractFirstHeading(raw); h != "" {
- heading = h
- raw = stripFirstHeading(raw)
- }
- content = renderMarkdown(raw)
- }
- }
-
- var photos []diaryPhoto
- for _, p := range monthPhotos {
- if p.Date.Day() == dayNum {
- photos = append(photos, p)
- }
- }
-
- sections = append(sections, diaryDaySection{
- Heading: heading,
- URL: dayURL,
- Content: content,
- Photos: photos,
- })
- }
-
- var buf bytes.Buffer
- if err := diaryMonthTmpl.Execute(&buf, diaryMonthData{Days: sections}); err != nil {
- log.Printf("diary month template: %v", err)
- return ""
- }
- return template.HTML(buf.String())
-}
-
-// renderDiaryDay renders the photo grid for a single day, sourcing photos
-// from the grandparent year folder.
-func (h *handler) renderDiaryDay(fsPath, urlPath string) template.HTML {
- monthFsPath := filepath.Dir(fsPath)
- yearFsPath := filepath.Dir(monthFsPath)
- yearURLPath := parentURL(parentURL(urlPath))
-
- year, err := strconv.Atoi(filepath.Base(yearFsPath))
- if err != nil {
- return ""
- }
- monthNum, err := strconv.Atoi(filepath.Base(monthFsPath))
- if err != nil {
- return ""
- }
- dayNum, err := strconv.Atoi(filepath.Base(fsPath))
- if err != nil {
- return ""
- }
-
- allPhotos := yearPhotos(yearFsPath, yearURLPath)
- var photos []diaryPhoto
- for _, p := range allPhotos {
- if p.Date.Year() == year && int(p.Date.Month()) == monthNum && p.Date.Day() == dayNum {
- photos = append(photos, p)
- }
- }
-
- if len(photos) == 0 {
- return ""
- }
-
- var buf bytes.Buffer
- if err := diaryDayTmpl.Execute(&buf, diaryDayData{Photos: photos}); err != nil {
- log.Printf("diary day template: %v", err)
- return ""
- }
- return template.HTML(buf.String())
-}
-
-func listEntries(fsPath, urlPath string) []entry {
- entries, err := os.ReadDir(fsPath)
- if err != nil {
- return nil
- }
-
- var folders, files []entry
- for _, e := range entries {
- name := e.Name()
- if strings.HasPrefix(name, ".") {
- continue
- }
- info, err := e.Info()
- if err != nil {
- continue
- }
- entryURL := path.Join(urlPath, name)
- if e.IsDir() {
- folders = append(folders, entry{
- Icon: iconFolder,
- Name: name,
- URL: entryURL + "/",
- Meta: info.ModTime().Format("2006-01-02"),
- })
- } else {
- if name == "index.md" {
- continue // rendered above, don't list it
- }
- files = append(files, entry{
- Icon: fileIcon(name),
- Name: name,
- URL: entryURL,
- Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
- })
- }
- }
-
- sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
- sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
-
- return append(folders, files...)
-}
-
-// Pixel-art SVG icons — outlined, crispEdges, uses currentColor.
-const (
- iconFolder template.HTML = ``
- iconDoc template.HTML = ``
- iconImage template.HTML = ``
- iconVideo template.HTML = ``
- iconAudio template.HTML = ``
- iconArchive template.HTML = ``
- iconGeneric template.HTML = ``
-)
-
-func fileIcon(name string) template.HTML {
- ext := strings.ToLower(path.Ext(name))
- switch ext {
- case ".md":
- return iconDoc
- case ".pdf":
- return iconDoc
- case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
- return iconImage
- case ".mp4", ".mkv", ".avi", ".mov":
- return iconVideo
- case ".mp3", ".flac", ".ogg", ".wav":
- return iconAudio
- case ".zip", ".tar", ".gz", ".7z":
- return iconArchive
- default:
- return iconGeneric
- }
-}
-
-func formatSize(b int64) string {
- switch {
- case b < 1024:
- return fmt.Sprintf("%d B", b)
- case b < 1024*1024:
- return fmt.Sprintf("%.1f KB", float64(b)/1024)
- default:
- return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
- }
-}
-
-func buildCrumbs(urlPath string) []crumb {
- if urlPath == "/" {
- return nil
- }
- parts := strings.Split(strings.Trim(urlPath, "/"), "/")
- crumbs := make([]crumb, len(parts))
- for i, p := range parts {
- crumbs[i] = crumb{
- Name: p,
- URL: "/" + strings.Join(parts[:i+1], "/") + "/",
- }
- }
- return crumbs
-}
-
-func pageTitle(urlPath string) string {
- if urlPath == "/" {
- return "Datascape"
- }
- parts := strings.Split(strings.Trim(urlPath, "/"), "/")
- return parts[len(parts)-1]
-}
diff --git a/render.go b/render.go
new file mode 100644
index 0000000..cbc3c7d
--- /dev/null
+++ b/render.go
@@ -0,0 +1,197 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "os"
+ "path"
+ "sort"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer/html"
+)
+
+var md = goldmark.New(
+ goldmark.WithExtensions(extension.GFM, extension.Table),
+ goldmark.WithParserOptions(parser.WithAutoHeadingID()),
+ goldmark.WithRendererOptions(html.WithUnsafe()),
+)
+
+type crumb struct{ Name, URL string }
+type entry struct {
+ Icon template.HTML
+ Name, URL, Meta string
+}
+
+type pageData struct {
+ Title string
+ Crumbs []crumb
+ CanEdit bool
+ EditMode bool
+ PostURL string
+ RawContent string
+ Content template.HTML
+ Entries []entry
+ SpecialContent template.HTML
+}
+
+// pageSettings holds the parsed contents of a .page-settings file.
+type pageSettings struct {
+ Type string
+}
+
+// renderMarkdown converts raw markdown to trusted HTML.
+func renderMarkdown(raw []byte) template.HTML {
+ var buf bytes.Buffer
+ if err := md.Convert(raw, &buf); err != nil {
+ return ""
+ }
+ return template.HTML(buf.String())
+}
+
+// extractFirstHeading returns the text of the first ATX heading in raw markdown,
+// or an empty string if none is found.
+func extractFirstHeading(raw []byte) string {
+ for _, line := range strings.SplitN(string(raw), "\n", 50) {
+ trimmed := strings.TrimSpace(line)
+ if !strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ text := strings.TrimSpace(strings.TrimLeft(trimmed, "#"))
+ if text != "" {
+ return text
+ }
+ }
+ return ""
+}
+
+// stripFirstHeading removes the first ATX heading line from raw markdown.
+func stripFirstHeading(raw []byte) []byte {
+ lines := strings.Split(string(raw), "\n")
+ for i, line := range lines {
+ if strings.HasPrefix(strings.TrimSpace(line), "#") {
+ result := append(lines[:i:i], lines[i+1:]...)
+ return []byte(strings.TrimLeft(strings.Join(result, "\n"), "\n"))
+ }
+ }
+ return raw
+}
+
+// parentURL returns the parent URL of a slash-terminated URL path.
+func parentURL(urlPath string) string {
+ parent := path.Dir(strings.TrimSuffix(urlPath, "/"))
+ if parent == "." || parent == "/" {
+ return "/"
+ }
+ return parent + "/"
+}
+
+func listEntries(fsPath, urlPath string) []entry {
+ entries, err := os.ReadDir(fsPath)
+ if err != nil {
+ return nil
+ }
+
+ var folders, files []entry
+ for _, e := range entries {
+ name := e.Name()
+ if strings.HasPrefix(name, ".") {
+ continue
+ }
+ info, err := e.Info()
+ if err != nil {
+ continue
+ }
+ entryURL := path.Join(urlPath, name)
+ if e.IsDir() {
+ folders = append(folders, entry{
+ Icon: iconFolder,
+ Name: name,
+ URL: entryURL + "/",
+ Meta: info.ModTime().Format("2006-01-02"),
+ })
+ } else {
+ if name == "index.md" {
+ continue // rendered above, don't list it
+ }
+ files = append(files, entry{
+ Icon: fileIcon(name),
+ Name: name,
+ URL: entryURL,
+ Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
+ })
+ }
+ }
+
+ sort.Slice(folders, func(i, j int) bool { return folders[i].Name < folders[j].Name })
+ sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name })
+
+ return append(folders, files...)
+}
+
+// Pixel-art SVG icons — outlined, crispEdges, uses currentColor.
+const (
+ iconFolder template.HTML = ``
+ iconDoc template.HTML = ``
+ iconImage template.HTML = ``
+ iconVideo template.HTML = ``
+ iconAudio template.HTML = ``
+ iconArchive template.HTML = ``
+ iconGeneric template.HTML = ``
+)
+
+func fileIcon(name string) template.HTML {
+ ext := strings.ToLower(path.Ext(name))
+ switch ext {
+ case ".md", ".pdf":
+ return iconDoc
+ case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
+ return iconImage
+ case ".mp4", ".mkv", ".avi", ".mov":
+ return iconVideo
+ case ".mp3", ".flac", ".ogg", ".wav":
+ return iconAudio
+ case ".zip", ".tar", ".gz", ".7z":
+ return iconArchive
+ default:
+ return iconGeneric
+ }
+}
+
+func formatSize(b int64) string {
+ switch {
+ case b < 1024:
+ return fmt.Sprintf("%d B", b)
+ case b < 1024*1024:
+ return fmt.Sprintf("%.1f KB", float64(b)/1024)
+ default:
+ return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
+ }
+}
+
+func buildCrumbs(urlPath string) []crumb {
+ if urlPath == "/" {
+ return nil
+ }
+ parts := strings.Split(strings.Trim(urlPath, "/"), "/")
+ crumbs := make([]crumb, len(parts))
+ for i, p := range parts {
+ crumbs[i] = crumb{
+ Name: p,
+ URL: "/" + strings.Join(parts[:i+1], "/") + "/",
+ }
+ }
+ return crumbs
+}
+
+func pageTitle(urlPath string) string {
+ if urlPath == "/" {
+ return "Datascape"
+ }
+ parts := strings.Split(strings.Trim(urlPath, "/"), "/")
+ return parts[len(parts)-1]
+}