Compare commits

...

3 Commits

Author SHA1 Message Date
19017bf136 Fix page creation 2026-04-13 13:07:20 +02:00
95ca30509c Refactor projcet structure 2026-04-13 12:39:01 +02:00
0e8e3b2636 Diary feature V1 2026-04-13 12:33:02 +02:00
6 changed files with 676 additions and 143 deletions

View File

@@ -1,3 +1,10 @@
function newPage() {
const name = prompt('New page name:');
if (!name || !name.trim()) return;
const slug = name.trim().replace(/\s+/g, '-');
window.location.href = window.location.pathname + slug + '/?edit';
}
(function () { (function () {
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (!e.altKey || !e.shiftKey) return; if (!e.altKey || !e.shiftKey) return;
@@ -5,7 +12,11 @@
case 'E': case 'E':
e.preventDefault(); e.preventDefault();
window.location.href = window.location.pathname + '?edit'; window.location.href = window.location.pathname + '?edit';
break; break;
case 'N':
e.preventDefault();
newPage();
break;
} }
}); });
})(); })();

View File

@@ -19,7 +19,8 @@
<a class="btn-cancel" href="{{.PostURL}}">CANCEL</a> <a class="btn-cancel" href="{{.PostURL}}">CANCEL</a>
<button class="btn-save" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button> <button class="btn-save" type="submit" form="edit-form" data-action="save" data-key="S" title="Save (S)">SAVE</button>
{{else if .CanEdit}} {{else if .CanEdit}}
<a class="edit-btn" href="?edit" title="Edit (Alt+Shift+E)">EDIT</a> <button class="new-btn" onclick="newPage()" title="New page (N)">NEW</button>
<a class="edit-btn" href="?edit" title="Edit page (E)">EDIT</a>
{{end}} {{end}}
</header> </header>
<main> <main>
@@ -45,10 +46,17 @@
<textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea> <textarea name="content" id="editor" autofocus>{{.RawContent}}</textarea>
</form> </form>
<script src="/_/editor.js"></script> <script src="/_/editor.js"></script>
{{else}} {{if .Content}} {{else}}
{{if .Content}}
<div class="content">{{.Content}}</div> <div class="content">{{.Content}}</div>
{{end}}
{{if .SpecialContent}}
<div class="diary">{{.SpecialContent}}</div>
{{end}}
{{if or .Content .SpecialContent}}
<script src="/_/content.js"></script> <script src="/_/content.js"></script>
{{end}} {{if .Entries}} {{end}}
{{if .Entries}}
<div class="listing"> <div class="listing">
<div class="listing-header">Contents</div> <div class="listing-header">Contents</div>
{{range .Entries}} {{range .Entries}}
@@ -60,8 +68,11 @@
{{end}} {{end}}
</div> </div>
{{else if not .Content}} {{else if not .Content}}
{{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}}
{{end}}
</main> </main>
</body> </body>
</html> </html>

View File

@@ -102,6 +102,25 @@ header {
color: #ffd54f; color: #ffd54f;
} }
.new-btn {
background: none;
border: none;
color: #ffb300;
font: inherit;
cursor: pointer;
padding: 0;
white-space: nowrap;
}
.new-btn::before {
content: "[";
}
.new-btn::after {
content: "]";
}
.new-btn:hover {
color: #ffd54f;
}
/* === Main === */ /* === Main === */
main { main {
max-width: 860px; max-width: 860px;
@@ -313,6 +332,7 @@ textarea:focus {
color: #ffb300; color: #ffb300;
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
text-shadow: inherit;
padding: 0; padding: 0;
} }
.btn-save::before { .btn-save::before {
@@ -330,6 +350,7 @@ textarea:focus {
border: none; border: none;
color: #ffb300; color: #ffb300;
font: inherit; font: inherit;
text-shadow: inherit;
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
text-decoration: none; text-decoration: none;
@@ -345,6 +366,54 @@ textarea:focus {
color: #ffb300; color: #ffb300;
} }
/* === Diary views === */
.diary-section {
margin: 2rem 0;
padding-top: 1.5rem;
border-top: 1px dashed #0a0;
}
.diary-section:first-child {
border-top: none;
padding-top: 0;
margin-top: 0;
}
.diary-heading {
font-size: 1.2rem;
color: white;
margin-bottom: 0.75rem;
font-weight: normal;
}
.diary-photo-count {
color: #888;
font-size: 0.85rem;
}
.diary-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 0.4rem;
margin-top: 0.75rem;
}
.diary-photo-grid a {
display: block;
line-height: 0;
}
.diary-photo-grid img {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.diary-section .content {
margin-bottom: 0.75rem;
}
/* === Empty state === */ /* === Empty state === */
.empty { .empty {
padding: 1rem; padding: 1rem;

309
diary.go Normal file
View 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())
}

212
main.go
View File

@@ -1,10 +1,8 @@
package main package main
import ( import (
"bytes"
"embed" "embed"
"flag" "flag"
"fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"log" "log"
@@ -12,43 +10,31 @@ import (
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"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"))
type crumb struct{ Name, URL string } // specialPage is the result returned by a pageTypeHandler.
type entry struct { // Content is injected into the page after the standard markdown content.
Icon template.HTML // SuppressListing hides the default file/folder listing.
Name, URL, Meta string type specialPage struct {
Content template.HTML
SuppressListing bool
} }
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
} }
// pageTypeHandlers is the registry. Each type registers itself via init().
var pageTypeHandlers []pageTypeHandler
func main() { func main() {
addr := flag.String("addr", ":8080", "listen address") addr := flag.String("addr", ":8080", "listen address")
wikiDir := flag.String("dir", "./wiki", "wiki root directory") wikiDir := flag.String("dir", "./wiki", "wiki root directory")
@@ -89,8 +75,7 @@ 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.
rel, err := filepath.Rel(h.root, fsPath) rel, err := filepath.Rel(h.root, fsPath)
@@ -101,6 +86,18 @@ fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
info, err := os.Stat(fsPath) info, err := os.Stat(fsPath)
if err != nil { if err != nil {
if os.IsNotExist(err) {
// Non-existent path: redirect GETs to the canonical slash form so
// the browser URL is consistent, then serve an empty folder page.
// POSTs must not be redirected — the form action has no trailing
// slash (path.Clean strips it) and the content would be lost.
if !strings.HasSuffix(r.URL.Path, "/") && r.Method != http.MethodPost {
http.Redirect(w, r, r.URL.Path+"/", http.StatusMovedPermanently)
return
}
h.serveDir(w, r, urlPath, fsPath)
return
}
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
@@ -128,26 +125,43 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
var rendered template.HTML var rendered template.HTML
if len(rawMD) > 0 && !editMode { if len(rawMD) > 0 && !editMode {
var buf bytes.Buffer rendered = renderMarkdown(rawMD)
if err := md.Convert(rawMD, &buf); err == nil { }
rendered = template.HTML(buf.String())
var special *specialPage
if !editMode {
for _, ph := range pageTypeHandlers {
if special = ph.handle(h.root, fsPath, urlPath); special != nil {
break
}
} }
} }
var entries []entry var entries []entry
if !editMode { if !editMode && (special == nil || !special.SuppressListing) {
entries = listEntries(fsPath, urlPath) entries = listEntries(fsPath, urlPath)
} }
title := pageTitle(urlPath)
if heading := extractFirstHeading(rawMD); heading != "" {
title = heading
}
var specialContent template.HTML
if special != nil {
specialContent = special.Content
}
data := pageData{ data := pageData{
Title: pageTitle(urlPath), 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,
SpecialContent: specialContent,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -169,6 +183,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return return
} }
} else { } else {
if err := os.MkdirAll(fsPath, 0755); err != nil {
http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
return
}
if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil { if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return return
@@ -177,110 +195,28 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
http.Redirect(w, r, urlPath, http.StatusSeeOther) http.Redirect(w, r, urlPath, http.StatusSeeOther)
} }
func listEntries(fsPath, urlPath string) []entry { // readPageSettings parses a .page-settings file in dir.
entries, err := os.ReadDir(fsPath) // Returns nil if the file does not exist.
// Format: one "key = value" pair per line; lines starting with # are comments.
func readPageSettings(dir string) *pageSettings {
data, err := os.ReadFile(filepath.Join(dir, ".page-settings"))
if err != nil { if err != nil {
return nil return nil
} }
s := &pageSettings{}
var folders, files []entry for _, line := range strings.Split(string(data), "\n") {
for _, e := range entries { line = strings.TrimSpace(line)
name := e.Name() if line == "" || strings.HasPrefix(line, "#") {
if strings.HasPrefix(name, ".") {
continue continue
} }
info, err := e.Info() parts := strings.SplitN(line, "=", 2)
if err != nil { if len(parts) != 2 {
continue continue
} }
entryURL := path.Join(urlPath, name) switch strings.TrimSpace(parts[0]) {
if e.IsDir() { case "type":
folders = append(folders, entry{ s.Type = strings.TrimSpace(parts[1])
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"),
})
} }
} }
return s
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
View 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]
}