325 lines
8.1 KiB
Go
325 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"html/template"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
)
|
|
|
|
var md goldmark.Markdown
|
|
|
|
// initMarkdown builds the package-level goldmark instance. Called once from
|
|
// main after the wiki root is known so the wiki-link extension can resolve
|
|
// targets against the filesystem.
|
|
func initMarkdown(root string) {
|
|
md = goldmark.New(
|
|
goldmark.WithExtensions(extension.GFM, extension.Table, newWikiLinkExt(root), newExtLinksExt()),
|
|
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
|
goldmark.WithRendererOptions(html.WithUnsafe()),
|
|
)
|
|
}
|
|
|
|
type entry struct {
|
|
Icon template.HTML
|
|
Name, URL, Meta string
|
|
// ThumbURL is set for thumbnailable files; the thumbnail view renders an
|
|
// <img> when it is non-empty and falls back to Icon otherwise.
|
|
ThumbURL string
|
|
// modTime/size carry the raw sort keys; the template only reads the
|
|
// formatted Meta string.
|
|
modTime time.Time
|
|
size int64
|
|
}
|
|
|
|
type pageData struct {
|
|
Title string
|
|
ParentURL string
|
|
CanEdit bool
|
|
EditMode bool
|
|
IsRoot bool
|
|
SectionIndex int // -1 = whole page; >=0 = section being edited
|
|
InsertBefore int // -1 = no insert; >=0 = splice new section at this index
|
|
PostURL string
|
|
RawContent string
|
|
Content template.HTML
|
|
Entries []entry
|
|
View string // listing view style: "list" or "thumbnail"
|
|
Sort string // listing sort key: "name" / "modified" / "size"
|
|
Order string // listing sort order: "asc" / "desc"
|
|
SpecialContent template.HTML
|
|
SidebarWidget template.HTML
|
|
SuppressTOC bool
|
|
RenderMS int64
|
|
}
|
|
|
|
// Allowed values for the listing view settings. Unknown values in the file or
|
|
// a POST body fall back to the first (default) value of each set.
|
|
const (
|
|
viewList = "list"
|
|
viewThumbnail = "thumbnail"
|
|
|
|
sortName = "name"
|
|
sortModified = "modified"
|
|
sortSize = "size"
|
|
|
|
orderAsc = "asc"
|
|
orderDesc = "desc"
|
|
)
|
|
|
|
// pageSettings holds the parsed contents of a .page-settings file. View, Sort,
|
|
// and Order are always valid once parsed (defaults applied on read).
|
|
type pageSettings struct {
|
|
Type string
|
|
View string
|
|
Sort string
|
|
Order string
|
|
}
|
|
|
|
// viewSettings returns the listing view/sort/order, applying defaults when the
|
|
// receiver is nil (no .page-settings file).
|
|
func (s *pageSettings) viewSettings() (view, sortKey, order string) {
|
|
if s == nil {
|
|
return viewList, sortName, orderAsc
|
|
}
|
|
return s.View, s.Sort, s.Order
|
|
}
|
|
|
|
func validateView(v string) string {
|
|
if v == viewThumbnail {
|
|
return viewThumbnail
|
|
}
|
|
return viewList
|
|
}
|
|
|
|
func validateSort(v string) string {
|
|
switch v {
|
|
case sortModified, sortSize:
|
|
return v
|
|
default:
|
|
return sortName
|
|
}
|
|
}
|
|
|
|
func validateOrder(v string) string {
|
|
if v == orderDesc {
|
|
return orderDesc
|
|
}
|
|
return orderAsc
|
|
}
|
|
|
|
var (
|
|
iconUp = readIcon("up")
|
|
iconFolder = readIcon("folder")
|
|
iconDoc = readIcon("doc")
|
|
iconImage = readIcon("image")
|
|
iconVideo = readIcon("video")
|
|
iconAudio = readIcon("audio")
|
|
iconArchive = readIcon("archive")
|
|
iconGeneric = readIcon("generic")
|
|
)
|
|
|
|
// 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 ""
|
|
}
|
|
out := rewriteTaskCheckboxes(buf.Bytes())
|
|
// Goldmark emits a bare `<table>`; tag it so it picks up the shared
|
|
// .data-table styling with the grid modifier (per-cell borders + header).
|
|
out = bytes.ReplaceAll(out, []byte("<table>"), []byte(`<table class="data-table data-table-grid">`))
|
|
return template.HTML(out)
|
|
}
|
|
|
|
// 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, sortKey, order 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"),
|
|
modTime: info.ModTime(),
|
|
})
|
|
} else {
|
|
if name == "index.md" {
|
|
continue // rendered above, don't list it
|
|
}
|
|
f := entry{
|
|
Icon: fileIcon(name),
|
|
Name: name,
|
|
URL: entryURL,
|
|
Meta: formatSize(info.Size()) + " · " + info.ModTime().Format("2006-01-02"),
|
|
modTime: info.ModTime(),
|
|
size: info.Size(),
|
|
}
|
|
if hasThumbnail(name) {
|
|
f.ThumbURL = thumbURL(path.Join(urlPath, url.PathEscape(name)), 300)
|
|
}
|
|
files = append(files, f)
|
|
}
|
|
}
|
|
|
|
// Folders always sort by name regardless of the chosen key (they have no
|
|
// meaningful byte size); files honor the chosen key. The chosen order
|
|
// applies to both groups.
|
|
sortEntries(folders, sortName, order)
|
|
sortEntries(files, sortKey, order)
|
|
|
|
// `..` row mirrors the header Up button so the listing itself is
|
|
// navigable without reaching for the header on mobile. Prepended after
|
|
// sort so it always sits at the top regardless of folder names.
|
|
var out []entry
|
|
if urlPath != "/" {
|
|
out = append(out, entry{
|
|
Icon: iconUp,
|
|
Name: "..",
|
|
URL: parentURL(urlPath),
|
|
})
|
|
}
|
|
out = append(out, folders...)
|
|
out = append(out, files...)
|
|
return out
|
|
}
|
|
|
|
// sortEntries sorts a single group (folders or files) in place by the given
|
|
// key, breaking ties on case-insensitive name, then reverses for descending
|
|
// order. The stable sort keeps the name tiebreak meaningful.
|
|
func sortEntries(group []entry, sortKey, order string) {
|
|
sort.SliceStable(group, func(i, j int) bool {
|
|
a, b := group[i], group[j]
|
|
cmp := 0
|
|
switch sortKey {
|
|
case sortModified:
|
|
if a.modTime.Before(b.modTime) {
|
|
cmp = -1
|
|
} else if a.modTime.After(b.modTime) {
|
|
cmp = 1
|
|
}
|
|
case sortSize:
|
|
if a.size < b.size {
|
|
cmp = -1
|
|
} else if a.size > b.size {
|
|
cmp = 1
|
|
}
|
|
}
|
|
if cmp == 0 {
|
|
an, bn := strings.ToLower(a.Name), strings.ToLower(b.Name)
|
|
if an < bn {
|
|
cmp = -1
|
|
} else if an > bn {
|
|
cmp = 1
|
|
}
|
|
}
|
|
if order == orderDesc {
|
|
return cmp > 0
|
|
}
|
|
return cmp < 0
|
|
})
|
|
}
|
|
|
|
func readIcon(name string) template.HTML {
|
|
b, _ := assets.ReadFile("assets/icons/" + name + ".svg")
|
|
return template.HTML(strings.TrimSpace(string(b)))
|
|
}
|
|
|
|
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 pageTitle(urlPath string) string {
|
|
if urlPath == "/" {
|
|
return "Datascape"
|
|
}
|
|
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
|
return parts[len(parts)-1]
|
|
}
|