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.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
}
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
SpecialContent template.HTML
SidebarWidget template.HTML
SuppressTOC bool
RenderMS int64
}
// pageSettings holds the parsed contents of a .page-settings file.
type pageSettings struct {
Type string
}
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 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 strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
})
sort.Slice(files, func(i, j int) bool {
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
// `..` 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
}
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]
}