From e0d2fb0b413523c480a735220d6e932cc7b83341 Mon Sep 17 00:00:00 2001 From: luxick Date: Tue, 21 Apr 2026 19:50:16 +0200 Subject: [PATCH] Wiki Links support --- assets/page-actions.js | 25 +++++ assets/page.html | 5 + assets/style.css | 17 ++++ main.go | 13 +++ moves.go | 219 +++++++++++++++++++++++++++++++++++++++++ render.go | 18 +++- wikilinks.go | 186 ++++++++++++++++++++++++++++++++++ 7 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 assets/page-actions.js create mode 100644 moves.go create mode 100644 wikilinks.go diff --git a/assets/page-actions.js b/assets/page-actions.js new file mode 100644 index 0000000..2864898 --- /dev/null +++ b/assets/page-actions.js @@ -0,0 +1,25 @@ +function movePage() { + const current = window.location.pathname; + const target = prompt('Move this page to (absolute path):', current); + if (target === null) return; + const clean = target.trim(); + if (!clean || !clean.startsWith('/')) { + alert('Move target must be an absolute path starting with /'); + return; + } + const form = document.createElement('form'); + form.method = 'POST'; + form.action = current + '?move=' + encodeURIComponent(clean); + document.body.appendChild(form); + form.submit(); +} + +function deletePage() { + const current = window.location.pathname; + if (!confirm('Delete ' + current + ' and everything inside it?')) return; + const form = document.createElement('form'); + form.method = 'POST'; + form.action = current + '?delete=1'; + document.body.appendChild(form); + form.submit(); +} diff --git a/assets/page.html b/assets/page.html index 5f28b1f..8f156da 100644 --- a/assets/page.html +++ b/assets/page.html @@ -9,6 +9,7 @@ +
@@ -24,6 +25,10 @@ {{else if .CanEdit}} EDIT + {{if not .IsRoot}} + + + {{end}} {{end}}
diff --git a/assets/style.css b/assets/style.css index 7e78eec..0098107 100644 --- a/assets/style.css +++ b/assets/style.css @@ -59,6 +59,15 @@ a:hover { color: var(--link-hover); } +/* Broken wiki link: target folder does not exist */ +.content a.broken { + color: var(--primary-hover); + text-decoration: line-through; +} +.content a.broken:hover { + color: var(--link-hover); +} + /* === Header === */ header { padding: 0.75rem 1rem; @@ -124,6 +133,14 @@ header { padding: 0 0.15rem; } +/* Destructive action */ +.danger { + color: var(--primary-hover); +} +.danger:hover { + color: var(--link-hover); +} + /* === Main === */ main { max-width: 860px; diff --git a/main.go b/main.go index bd12455..6267d60 100644 --- a/main.go +++ b/main.go @@ -51,6 +51,8 @@ func main() { log.Fatal(err) } + initMarkdown(root) + authKey, err := loadOrCreateAuthKey(root) if err != nil { log.Fatal(err) @@ -183,6 +185,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa Crumbs: buildCrumbs(urlPath), CanEdit: true, EditMode: editMode, + IsRoot: urlPath == "/", SectionIndex: sectionIndex, PostURL: urlPath, RawContent: rawContent, @@ -198,6 +201,16 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa } func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { + query := r.URL.Query() + if query.Has("delete") { + h.handleDelete(w, r, urlPath, fsPath) + return + } + if _, ok := query["move"]; ok { + h.handleMove(w, r, urlPath, fsPath, query.Get("move")) + return + } + if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return diff --git a/moves.go b/moves.go new file mode 100644 index 0000000..e5e1913 --- /dev/null +++ b/moves.go @@ -0,0 +1,219 @@ +package main + +import ( + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strings" +) + +// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL and +// rewrites every [[...]] wiki link across the tree that targets the old path +// or any descendant. All rewritten files are held in memory for rollback. +func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string) { + oldPath := normalizeMovePath(srcURL) + if oldPath == "/" { + http.Error(w, "cannot move wiki root", http.StatusBadRequest) + return + } + + newPath, err := validateAndNormalizeNewPath(dstURL) + if err != nil { + http.Error(w, "invalid destination: "+err.Error(), http.StatusBadRequest) + return + } + if newPath == oldPath { + http.Error(w, "destination equals source", http.StatusBadRequest) + return + } + if strings.HasPrefix(newPath, oldPath+"/") { + http.Error(w, "destination is inside source", http.StatusBadRequest) + return + } + + if info, err := os.Stat(srcFsPath); err != nil || !info.IsDir() { + http.NotFound(w, r) + return + } + dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/"))) + if _, err := os.Stat(dstFsPath); err == nil { + http.Error(w, "destination already exists", http.StatusConflict) + return + } + + // Phase 1: walk the tree and rewrite every index.md whose content changes. + // Keep the pre-rewrite bytes in memory so we can revert on failure. + rewritten := map[string][]byte{} + walkErr := filepath.WalkDir(h.root, func(fsPath string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || d.Name() != "index.md" { + return nil + } + orig, err := os.ReadFile(fsPath) + if err != nil { + return err + } + updated, changed := rewriteWikiLinks(orig, oldPath, newPath) + if !changed { + return nil + } + if err := writeFileAtomic(fsPath, updated, 0644); err != nil { + return fmt.Errorf("write %s: %w", fsPath, err) + } + rewritten[fsPath] = orig + return nil + }) + if walkErr != nil { + rollbackRewrites(rewritten) + http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError) + return + } + + // Phase 2: create intermediate parent folders for the destination. + if parent := filepath.Dir(dstFsPath); parent != "" { + if err := os.MkdirAll(parent, 0755); err != nil { + rollbackRewrites(rewritten) + http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError) + return + } + } + + // Phase 3: rename the source folder into place. + if err := os.Rename(srcFsPath, dstFsPath); err != nil { + rollbackRewrites(rewritten) + http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError) + return + } + + http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther) +} + +// handleDelete removes the folder at fsPath (URL urlPath) and redirects to +// the parent. Refuses to touch the wiki root. +func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { + if normalizeMovePath(urlPath) == "/" { + http.Error(w, "cannot delete wiki root", http.StatusBadRequest) + return + } + if info, err := os.Stat(fsPath); err != nil || !info.IsDir() { + http.NotFound(w, r) + return + } + if err := os.RemoveAll(fsPath); err != nil { + http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther) +} + +// normalizeMovePath returns the absolute path with any trailing slash removed, +// except for the wiki root which is always "/". +func normalizeMovePath(p string) string { + if p == "" || p == "/" { + return "/" + } + return "/" + strings.Trim(p, "/") +} + +// validateAndNormalizeNewPath returns the cleaned absolute path or an error +// describing why the input was rejected. Empty/root paths, relative paths, +// and paths with bad segments are all invalid. +func validateAndNormalizeNewPath(raw string) (string, error) { + if !strings.HasPrefix(raw, "/") { + return "", fmt.Errorf("must start with /") + } + trimmed := strings.Trim(raw, "/") + if trimmed == "" { + return "", fmt.Errorf("cannot target the wiki root") + } + for _, seg := range strings.Split(trimmed, "/") { + if seg == "" { + return "", fmt.Errorf("empty segment") + } + if seg == "." || seg == ".." { + return "", fmt.Errorf("segment %q is not allowed", seg) + } + if strings.ContainsAny(seg, "\\\x00") { + return "", fmt.Errorf("segment contains an invalid character") + } + } + return "/" + trimmed, nil +} + +// rewriteWikiLinks returns (newContent, changed). Any [[target]] or +// [[target|display]] whose target equals oldPath or begins with oldPath+"/" +// has its target rewritten to the corresponding position under newPath. +func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) { + changed := false + out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte { + parts := wikiLinkPattern.FindSubmatch(match) + if parts == nil { + return match + } + target := strings.TrimSpace(string(parts[1])) + normTarget := normalizeMovePath(target) + var newTarget string + switch { + case normTarget == oldPath: + newTarget = newPath + case strings.HasPrefix(normTarget, oldPath+"/"): + newTarget = newPath + strings.TrimPrefix(normTarget, oldPath) + default: + return match + } + changed = true + suffix := "" + if len(parts[2]) > 0 { + suffix = string(parts[2]) + } + return []byte("[[" + newTarget + suffix + "]]") + }) + return out, changed +} + +// rollbackRewrites restores the given files to their pre-rewrite contents. +// Errors are logged; best-effort since we're already in a failure path. +func rollbackRewrites(rewritten map[string][]byte) { + for path, orig := range rewritten { + if err := writeFileAtomic(path, orig, 0644); err != nil { + log.Printf("rollback %s: %v", path, err) + } + } +} + +// writeFileAtomic writes data to a temp file in the same directory as path +// and renames it into place so readers never observe a partial file. +func writeFileAtomic(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tmpPath := tmp.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpPath) + } + }() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Chmod(tmpPath, perm); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + cleanup = false + return nil +} diff --git a/render.go b/render.go index 52aeea8..2b80e46 100644 --- a/render.go +++ b/render.go @@ -15,11 +15,18 @@ import ( "github.com/yuin/goldmark/renderer/html" ) -var md = goldmark.New( - goldmark.WithExtensions(extension.GFM, extension.Table), - goldmark.WithParserOptions(parser.WithAutoHeadingID()), - goldmark.WithRendererOptions(html.WithUnsafe()), -) +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)), + goldmark.WithParserOptions(parser.WithAutoHeadingID()), + goldmark.WithRendererOptions(html.WithUnsafe()), + ) +} type crumb struct{ Name, URL string } type entry struct { @@ -32,6 +39,7 @@ type pageData struct { Crumbs []crumb CanEdit bool EditMode bool + IsRoot bool SectionIndex int // -1 = whole page; >=0 = section being edited PostURL string RawContent string diff --git a/wikilinks.go b/wikilinks.go new file mode 100644 index 0000000..e8e7518 --- /dev/null +++ b/wikilinks.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// wikiLinkRe matches [[target]] and [[target|display]] anchored at the start +// of the current inline reader. Target and display forbid newlines and +// brackets; target additionally forbids the pipe separator. +var wikiLinkRe = regexp.MustCompile(`^\[\[([^\[\]\|\n]+)(?:\|([^\[\]\n]+))?\]\]`) + +// wikiLinkPattern matches wiki-link tokens anywhere in a markdown source. +// Used by the move-endpoint rewriter; not by the goldmark parser. +var wikiLinkPattern = regexp.MustCompile(`\[\[([^\[\]\n\|]+)(\|[^\[\]\n]+)?\]\]`) + +// wikiLinkNode is the AST node produced by wikiLinkParser. +type wikiLinkNode struct { + ast.BaseInline + Target []byte + Display []byte +} + +var kindWikiLink = ast.NewNodeKind("WikiLink") + +func (n *wikiLinkNode) Kind() ast.NodeKind { return kindWikiLink } + +func (n *wikiLinkNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, map[string]string{ + "Target": string(n.Target), + "Display": string(n.Display), + }, nil) +} + +// isValidWikiTarget rejects targets that are not absolute or that contain +// traversal / empty segments. Matches the validation used by the move endpoint. +func isValidWikiTarget(target []byte) bool { + if len(target) == 0 || target[0] != '/' { + return false + } + trimmed := strings.Trim(string(target), "/") + if trimmed == "" { + return true // root link + } + for _, seg := range strings.Split(trimmed, "/") { + if seg == "" || seg == "." || seg == ".." { + return false + } + } + return true +} + +type wikiLinkParser struct{} + +func (p *wikiLinkParser) Trigger() []byte { return []byte{'['} } + +func (p *wikiLinkParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + line, _ := block.PeekLine() + if len(line) < 4 || line[0] != '[' || line[1] != '[' { + return nil + } + m := wikiLinkRe.FindSubmatchIndex(line) + if m == nil { + return nil + } + target := bytes.TrimSpace(line[m[2]:m[3]]) + if !isValidWikiTarget(target) { + return nil + } + var display []byte + if m[4] != -1 { + display = bytes.TrimSpace(line[m[4]:m[5]]) + } + block.Advance(m[1]) + return &wikiLinkNode{ + Target: append([]byte(nil), target...), + Display: append([]byte(nil), display...), + } +} + +// normalizeWikiTarget strips a trailing slash (but leaves "/" intact) and +// returns the cleaned absolute path. +func normalizeWikiTarget(target string) string { + if target == "/" { + return "/" + } + return "/" + strings.Trim(target, "/") +} + +// wikiTargetHref converts a wiki target to a URL href with each segment +// percent-encoded and a trailing slash appended. +func wikiTargetHref(target string) string { + target = normalizeWikiTarget(target) + if target == "/" { + return "/" + } + var b strings.Builder + for _, seg := range strings.Split(strings.TrimPrefix(target, "/"), "/") { + b.WriteByte('/') + b.WriteString(url.PathEscape(seg)) + } + b.WriteByte('/') + return b.String() +} + +// wikiTargetExists reports whether the on-disk folder backing the target +// exists under root. +func wikiTargetExists(root, target string) bool { + target = normalizeWikiTarget(target) + fsPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(target, "/"))) + info, err := os.Stat(fsPath) + return err == nil && info.IsDir() +} + +// wikiDefaultDisplay returns the last segment of a target, or "/" for the root. +func wikiDefaultDisplay(target string) string { + target = normalizeWikiTarget(target) + if target == "/" { + return "/" + } + segs := strings.Split(strings.TrimPrefix(target, "/"), "/") + return segs[len(segs)-1] +} + +type wikiLinkRenderer struct { + root string +} + +func (r *wikiLinkRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(kindWikiLink, r.render) +} + +func (r *wikiLinkRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + n := node.(*wikiLinkNode) + target := string(n.Target) + href := wikiTargetHref(target) + display := string(n.Display) + if display == "" { + display = wikiDefaultDisplay(target) + } + broken := !wikiTargetExists(r.root, target) + + w.WriteString(``) + w.Write(util.EscapeHTML([]byte(display))) + w.WriteString(``) + return ast.WalkContinue, nil +} + +type wikiLinkExt struct{ root string } + +// newWikiLinkExt returns a goldmark extension that turns [[...]] tokens into +// links resolved against root. +func newWikiLinkExt(root string) goldmark.Extender { + return &wikiLinkExt{root: root} +} + +func (e *wikiLinkExt) Extend(m goldmark.Markdown) { + // Priority 199 — one higher than the default link parser (200) so + // [[...]] is consumed before the default parser sees the outer `[`. + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&wikiLinkParser{}, 199), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(&wikiLinkRenderer{root: e.root}, 500), + )) +}