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),
+ ))
+}