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}}{{.Name}}{{end}}
{{end}}
{{end}}`, +)) + +var diaryDayTmpl = template.Must(template.New("diary-day").Parse( + `{{if .Photos}}
{{range .Photos}}{{.Name}}{{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}}{{.Name}}{{end}}
{{end}}
{{end}}`, -)) - -var diaryDayTmpl = template.Must(template.New("diary-day").Parse( - `{{if .Photos}}
{{range .Photos}}{{.Name}}{{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] +}