Initial version
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.claude/
|
||||||
30
README.md
Normal file
30
README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# datascape
|
||||||
|
|
||||||
|
Minimal self-hosted personal wiki. Folders are pages.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -dir ./wiki -addr :8080
|
||||||
|
go run . -dir ./wiki -addr :8080 -user me -pass secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# local
|
||||||
|
go build -o datascape .
|
||||||
|
|
||||||
|
# QNAP NAS (linux/arm64)
|
||||||
|
GOOS=linux GOARCH=arm64 go build -o datascape .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Action | How |
|
||||||
|
|--------|-----|
|
||||||
|
| Browse | Navigate folders at `/` |
|
||||||
|
| Read | Any folder with `index.md` renders it as HTML |
|
||||||
|
| Edit | Append `?edit` to any folder URL, or click **Edit** |
|
||||||
|
| Save | POST from the edit form writes `index.md` to disk |
|
||||||
|
| Files | Drop PDFs, images, etc. next to `index.md` — they appear in the listing |
|
||||||
182
assets/style.css
Normal file
182
assets/style.css
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #1e1e2e;
|
||||||
|
--surface: #313244;
|
||||||
|
--border: #45475a;
|
||||||
|
--text: #cdd6f4;
|
||||||
|
--muted: #6c7086;
|
||||||
|
--accent: #89b4fa;
|
||||||
|
--accent-dim: #1d2a3f;
|
||||||
|
--green: #a6e3a1;
|
||||||
|
--red: #f38ba8;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
--font-sans: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
header a { color: var(--text); font-weight: 600; }
|
||||||
|
header .sep { color: var(--muted); }
|
||||||
|
|
||||||
|
.breadcrumb { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; flex: 1; }
|
||||||
|
.breadcrumb a { color: var(--accent); }
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.edit-btn:hover { background: var(--accent); color: var(--bg); text-decoration: none; }
|
||||||
|
|
||||||
|
main { max-width: 860px; margin: 0 auto; padding: 1.5rem 1rem; }
|
||||||
|
|
||||||
|
.content { margin-bottom: 2rem; }
|
||||||
|
|
||||||
|
/* Markdown rendered content */
|
||||||
|
.content h1, .content h2, .content h3,
|
||||||
|
.content h4, .content h5, .content h6 {
|
||||||
|
margin: 1.25rem 0 0.5rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.content h1 { font-size: 1.75rem; border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; }
|
||||||
|
.content h2 { font-size: 1.4rem; }
|
||||||
|
.content h3 { font-size: 1.15rem; }
|
||||||
|
.content p { margin: 0.75rem 0; }
|
||||||
|
.content ul, .content ol { margin: 0.75rem 0 0.75rem 1.5rem; }
|
||||||
|
.content li { margin: 0.25rem 0; }
|
||||||
|
.content blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.content code {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.875em;
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.content pre {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.content pre code { background: none; padding: 0; }
|
||||||
|
.content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.content th, .content td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.content th { background: var(--surface); }
|
||||||
|
.content hr { border: none; border-top: 1px solid var(--border); margin: 1.5rem 0; }
|
||||||
|
.content img { max-width: 100%; border-radius: 4px; }
|
||||||
|
|
||||||
|
/* File/folder listing */
|
||||||
|
.listing { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||||
|
.listing-header {
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.listing-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.listing-item:hover { background: var(--surface); }
|
||||||
|
.listing-item .icon { font-size: 1rem; width: 1.25rem; text-align: center; flex-shrink: 0; }
|
||||||
|
.listing-item a { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.listing-item .meta { color: var(--muted); font-size: 0.8rem; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Edit form */
|
||||||
|
.edit-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
.edit-form textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 60vh;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 1rem;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.edit-form textarea:focus { border-color: var(--accent); }
|
||||||
|
.form-actions { display: flex; gap: 0.75rem; }
|
||||||
|
.btn-save {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
border: none;
|
||||||
|
padding: 0.6rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-save:hover { opacity: 0.9; }
|
||||||
|
.btn-cancel {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.6rem 1.25rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.btn-cancel:hover { border-color: var(--text); color: var(--text); text-decoration: none; }
|
||||||
|
|
||||||
|
.empty { color: var(--muted); font-style: italic; padding: 1rem; text-align: center; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
header { padding: 0.5rem 0.75rem; }
|
||||||
|
main { padding: 1rem 0.75rem; }
|
||||||
|
.edit-form textarea { min-height: 50vh; }
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module datascape
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require github.com/yuin/goldmark v1.8.2
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
|
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
309
main.go
Normal file
309
main.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark"
|
||||||
|
"github.com/yuin/goldmark/extension"
|
||||||
|
"github.com/yuin/goldmark/parser"
|
||||||
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed assets/*
|
||||||
|
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("").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} — datascape</title>
|
||||||
|
<link rel="stylesheet" href="/_/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav class="breadcrumb">
|
||||||
|
<a href="/">~</a>
|
||||||
|
{{range .Crumbs}}<span class="sep">/</span><a href="{{.URL}}">{{.Name}}</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{if .CanEdit}}<a class="edit-btn" href="?edit">Edit</a>{{end}}
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{{if .EditMode}}
|
||||||
|
<form class="edit-form" method="POST" action="{{.PostURL}}">
|
||||||
|
<textarea name="content" autofocus>{{.RawContent}}</textarea>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn-save" type="submit">Save</button>
|
||||||
|
<a class="btn-cancel" href="{{.PostURL}}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
{{if .Content}}<div class="content">{{.Content}}</div>{{end}}
|
||||||
|
{{if .Entries}}
|
||||||
|
<div class="listing">
|
||||||
|
<div class="listing-header">Contents</div>
|
||||||
|
{{range .Entries}}
|
||||||
|
<div class="listing-item">
|
||||||
|
<span class="icon">{{.Icon}}</span>
|
||||||
|
<a href="{{.URL}}">{{.Name}}</a>
|
||||||
|
<span class="meta">{{.Meta}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else if not .Content}}<p class="empty">Empty folder — <a href="?edit">create index.md</a></p>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`))
|
||||||
|
|
||||||
|
type crumb struct{ Name, URL string }
|
||||||
|
type entry struct{ Icon, Name, URL, Meta string }
|
||||||
|
|
||||||
|
type pageData struct {
|
||||||
|
Title string
|
||||||
|
Crumbs []crumb
|
||||||
|
CanEdit bool
|
||||||
|
EditMode bool
|
||||||
|
PostURL string
|
||||||
|
RawContent string
|
||||||
|
Content template.HTML
|
||||||
|
Entries []entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
addr := flag.String("addr", ":8080", "listen address")
|
||||||
|
wikiDir := flag.String("dir", "./wiki", "wiki root directory")
|
||||||
|
user := flag.String("user", "", "basic auth username (empty = no auth)")
|
||||||
|
pass := flag.String("pass", "", "basic auth password")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
root, err := filepath.Abs(*wikiDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(root, 0755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &handler{root: root, user: *user, pass: *pass}
|
||||||
|
|
||||||
|
staticFS, _ := fs.Sub(assets, "assets")
|
||||||
|
http.Handle("/_/", http.StripPrefix("/_/", http.FileServer(http.FS(staticFS))))
|
||||||
|
http.Handle("/", h)
|
||||||
|
|
||||||
|
log.Printf("datascape listening on %s, wiki at %s", *addr, root)
|
||||||
|
log.Fatal(http.ListenAndServe(*addr, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
type handler struct {
|
||||||
|
root, user, pass string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.user != "" {
|
||||||
|
u, p, ok := r.BasicAuth()
|
||||||
|
if !ok || u != h.user || p != h.pass {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="datascape"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPath := path.Clean("/" + r.URL.Path)
|
||||||
|
fsPath := filepath.Join(h.root, filepath.FromSlash(urlPath))
|
||||||
|
|
||||||
|
// Security: ensure the resolved path stays within root.
|
||||||
|
rel, err := filepath.Rel(h.root, fsPath)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(fsPath)
|
||||||
|
if err != nil {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
h.serveDir(w, r, urlPath, fsPath)
|
||||||
|
} else {
|
||||||
|
http.ServeFile(w, r, fsPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
|
_, editMode := r.URL.Query()["edit"]
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
h.handlePost(w, r, urlPath, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
|
rawMD, _ := os.ReadFile(indexPath)
|
||||||
|
|
||||||
|
var rendered template.HTML
|
||||||
|
if len(rawMD) > 0 && !editMode {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := md.Convert(rawMD, &buf); err == nil {
|
||||||
|
rendered = template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []entry
|
||||||
|
if !editMode {
|
||||||
|
entries = listEntries(fsPath, urlPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
postURL := urlPath
|
||||||
|
if urlPath == "/" {
|
||||||
|
postURL = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := pageData{
|
||||||
|
Title: pageTitle(urlPath),
|
||||||
|
Crumbs: buildCrumbs(urlPath),
|
||||||
|
CanEdit: true,
|
||||||
|
EditMode: editMode,
|
||||||
|
PostURL: postURL,
|
||||||
|
RawContent: string(rawMD),
|
||||||
|
Content: rendered,
|
||||||
|
Entries: entries,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tmpl.Execute(w, data); err != nil {
|
||||||
|
log.Printf("template error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content := r.FormValue("content")
|
||||||
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
|
if err := os.WriteFile(indexPath, []byte(content), 0644); err != nil {
|
||||||
|
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
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: "📁",
|
||||||
|
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...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileIcon(name string) string {
|
||||||
|
ext := strings.ToLower(path.Ext(name))
|
||||||
|
switch ext {
|
||||||
|
case ".md":
|
||||||
|
return "📄"
|
||||||
|
case ".pdf":
|
||||||
|
return "📕"
|
||||||
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg":
|
||||||
|
return "🖼"
|
||||||
|
case ".mp4", ".mkv", ".avi", ".mov":
|
||||||
|
return "🎬"
|
||||||
|
case ".mp3", ".flac", ".ogg", ".wav":
|
||||||
|
return "🎵"
|
||||||
|
case ".zip", ".tar", ".gz", ".7z":
|
||||||
|
return "📦"
|
||||||
|
default:
|
||||||
|
return "📎"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "home"
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||||
|
return parts[len(parts)-1]
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user