Refactor projcet structure
This commit is contained in:
@@ -49,10 +49,10 @@
|
|||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
<div class="content">{{.Content}}</div>
|
<div class="content">{{.Content}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .DiaryContent}}
|
{{if .SpecialContent}}
|
||||||
<div class="diary">{{.DiaryContent}}</div>
|
<div class="diary">{{.SpecialContent}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if or .Content .DiaryContent}}
|
{{if or .Content .SpecialContent}}
|
||||||
<script src="/_/content.js"></script>
|
<script src="/_/content.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Entries}}
|
{{if .Entries}}
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{else if not .Content}}
|
{{else if not .Content}}
|
||||||
{{if not .DiaryContent}}
|
{{if not .SpecialContent}}
|
||||||
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
<p class="empty">Empty folder — <a href="?edit">[CREATE]</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
309
diary.go
Normal file
309
diary.go
Normal file
@@ -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}}<div class="diary-section"><h2 class="diary-heading"><a href="{{.URL}}">{{.Name}}</a>{{if .PhotoCount}} <span class="diary-photo-count">({{.PhotoCount}} photos)</span>{{end}}</h2></div>{{end}}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
var diaryMonthTmpl = template.Must(template.New("diary-month").Parse(
|
||||||
|
`{{range .Days}}<div class="diary-section"><h2 class="diary-heading">{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}</h2>{{if .Content}}<div class="content">{{.Content}}</div>{{end}}{{if .Photos}}<div class="diary-photo-grid">{{range .Photos}}<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>{{end}}</div>{{end}}</div>{{end}}`,
|
||||||
|
))
|
||||||
|
|
||||||
|
var diaryDayTmpl = template.Must(template.New("diary-day").Parse(
|
||||||
|
`{{if .Photos}}<div class="diary-photo-grid">{{range .Photos}}<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>{{end}}</div>{{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())
|
||||||
|
}
|
||||||
529
main.go
529
main.go
@@ -1,99 +1,39 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"embed"
|
"embed"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"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/*
|
//go:embed assets/*
|
||||||
var assets embed.FS
|
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"))
|
var tmpl = template.Must(template.New("page.html").ParseFS(assets, "assets/page.html"))
|
||||||
|
|
||||||
// Diary sub-templates — executed into a buffer and injected as DiaryContent.
|
// specialPage is the result returned by a pageTypeHandler.
|
||||||
var diaryYearTmpl = template.Must(template.New("diary-year").Parse(
|
// Content is injected into the page after the standard markdown content.
|
||||||
`{{range .Months}}<div class="diary-section"><h2 class="diary-heading"><a href="{{.URL}}">{{.Name}}</a>{{if .PhotoCount}} <span class="diary-photo-count">({{.PhotoCount}} photos)</span>{{end}}</h2></div>{{end}}`,
|
// SuppressListing hides the default file/folder listing.
|
||||||
))
|
type specialPage struct {
|
||||||
|
Content template.HTML
|
||||||
var diaryMonthTmpl = template.Must(template.New("diary-month").Parse(
|
SuppressListing bool
|
||||||
`{{range .Days}}<div class="diary-section"><h2 class="diary-heading">{{if .URL}}<a href="{{.URL}}">{{.Heading}}</a>{{else}}{{.Heading}}{{end}}</h2>{{if .Content}}<div class="content">{{.Content}}</div>{{end}}{{if .Photos}}<div class="diary-photo-grid">{{range .Photos}}<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>{{end}}</div>{{end}}</div>{{end}}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
var diaryDayTmpl = template.Must(template.New("diary-day").Parse(
|
|
||||||
`{{if .Photos}}<div class="diary-photo-grid">{{range .Photos}}<a href="{{.URL}}" target="_blank"><img src="{{.URL}}" alt="{{.Name}}" loading="lazy"></a>{{end}}</div>{{end}}`,
|
|
||||||
))
|
|
||||||
|
|
||||||
type crumb struct{ Name, URL string }
|
|
||||||
type entry struct {
|
|
||||||
Icon template.HTML
|
|
||||||
Name, URL, Meta string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
// pageTypeHandler is implemented by each special folder type (diary, gallery, …).
|
||||||
Title string
|
// handle returns nil when the handler does not apply to the given path.
|
||||||
Crumbs []crumb
|
type pageTypeHandler interface {
|
||||||
CanEdit bool
|
handle(root, fsPath, urlPath string) *specialPage
|
||||||
EditMode bool
|
|
||||||
PostURL string
|
|
||||||
RawContent string
|
|
||||||
Content template.HTML
|
|
||||||
Entries []entry
|
|
||||||
DiaryContent template.HTML
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// pageSettings holds the parsed contents of a .page-settings file.
|
// pageTypeHandlers is the registry. Each type registers itself via init().
|
||||||
type pageSettings struct {
|
var pageTypeHandlers []pageTypeHandler
|
||||||
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 }
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := flag.String("addr", ":8080", "listen address")
|
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)
|
urlPath := path.Clean("/" + r.URL.Path)
|
||||||
|
|
||||||
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||||
|
|
||||||
// Security: ensure the resolved path stays within root.
|
// 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)
|
rendered = renderMarkdown(rawMD)
|
||||||
}
|
}
|
||||||
|
|
||||||
diaryDepth, isDiary := h.findDiaryContext(fsPath)
|
var special *specialPage
|
||||||
|
if !editMode {
|
||||||
// For diary sub-pages (depth > 0) the diary content replaces the file listing.
|
for _, ph := range pageTypeHandlers {
|
||||||
var entries []entry
|
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
|
||||||
if !editMode && (!isDiary || diaryDepth == 0) {
|
break
|
||||||
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 entries []entry
|
||||||
|
if !editMode && (special == nil || !special.SuppressListing) {
|
||||||
|
entries = listEntries(fsPath, urlPath)
|
||||||
|
}
|
||||||
|
|
||||||
title := pageTitle(urlPath)
|
title := pageTitle(urlPath)
|
||||||
if h := extractFirstHeading(rawMD); h != "" {
|
if heading := extractFirstHeading(rawMD); heading != "" {
|
||||||
title = h
|
title = heading
|
||||||
|
}
|
||||||
|
|
||||||
|
var specialContent template.HTML
|
||||||
|
if special != nil {
|
||||||
|
specialContent = special.Content
|
||||||
}
|
}
|
||||||
|
|
||||||
data := pageData{
|
data := pageData{
|
||||||
Title: title,
|
Title: title,
|
||||||
Crumbs: buildCrumbs(urlPath),
|
Crumbs: buildCrumbs(urlPath),
|
||||||
CanEdit: true,
|
CanEdit: true,
|
||||||
EditMode: editMode,
|
EditMode: editMode,
|
||||||
PostURL: urlPath,
|
PostURL: urlPath,
|
||||||
RawContent: string(rawMD),
|
RawContent: string(rawMD),
|
||||||
Content: rendered,
|
Content: rendered,
|
||||||
Entries: entries,
|
Entries: entries,
|
||||||
DiaryContent: diaryContent,
|
SpecialContent: specialContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
@@ -274,392 +212,3 @@ func readPageSettings(dir string) *pageSettings {
|
|||||||
}
|
}
|
||||||
return s
|
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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>`
|
|
||||||
iconDoc template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M3 1h7l4 4v10H3zM10 1v4h4M5 8h6M5 11h4"/></svg>`
|
|
||||||
iconImage template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="2" width="14" height="12"/><path d="M1 11l4-4 3 3 2-2 5 5"/><rect x="10" y="4" width="2" height="2" fill="currentColor" stroke="none"/></svg>`
|
|
||||||
iconVideo template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="3" width="14" height="10"/><path d="M6 6v4l5-2z" fill="currentColor" stroke="none"/></svg>`
|
|
||||||
iconAudio template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="currentColor" stroke="none" shape-rendering="crispEdges"><path d="M2 6h3l4-3v10l-4-3H2z"/><rect x="11" y="5" width="2" height="1"/><rect x="11" y="7" width="3" height="1"/><rect x="11" y="9" width="2" height="1"/></svg>`
|
|
||||||
iconArchive template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="1" width="14" height="4"/><path d="M1 5v10h14V5M7 3h2"/><rect x="7" y="6" width="2" height="2" fill="currentColor" stroke="none"/><rect x="7" y="9" width="2" height="1" fill="currentColor" stroke="none"/></svg>`
|
|
||||||
iconGeneric template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M3 1h7l4 4v10H3zM10 1v4h4"/></svg>`
|
|
||||||
)
|
|
||||||
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|||||||
197
render.go
Normal file
197
render.go
Normal file
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M1 6h14v8H1zm0 0V4h5l1 2"/></svg>`
|
||||||
|
iconDoc template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M3 1h7l4 4v10H3zM10 1v4h4M5 8h6M5 11h4"/></svg>`
|
||||||
|
iconImage template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="2" width="14" height="12"/><path d="M1 11l4-4 3 3 2-2 5 5"/><rect x="10" y="4" width="2" height="2" fill="currentColor" stroke="none"/></svg>`
|
||||||
|
iconVideo template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="3" width="14" height="10"/><path d="M6 6v4l5-2z" fill="currentColor" stroke="none"/></svg>`
|
||||||
|
iconAudio template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="currentColor" stroke="none" shape-rendering="crispEdges"><path d="M2 6h3l4-3v10l-4-3H2z"/><rect x="11" y="5" width="2" height="1"/><rect x="11" y="7" width="3" height="1"/><rect x="11" y="9" width="2" height="1"/></svg>`
|
||||||
|
iconArchive template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><rect x="1" y="1" width="14" height="4"/><path d="M1 5v10h14V5M7 3h2"/><rect x="7" y="6" width="2" height="2" fill="currentColor" stroke="none"/><rect x="7" y="9" width="2" height="1" fill="currentColor" stroke="none"/></svg>`
|
||||||
|
iconGeneric template.HTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges"><path d="M3 1h7l4 4v10H3zM10 1v4h4"/></svg>`
|
||||||
|
)
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user