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 // 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 ``; tag it so it picks up the shared // .data-table styling with the grid modifier (per-cell borders + header). out = bytes.ReplaceAll(out, []byte("
"), []byte(`
`)) 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] }