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] }