Wiki Links support

This commit is contained in:
2026-04-21 19:50:16 +02:00
parent 9639a70572
commit e0d2fb0b41
7 changed files with 478 additions and 5 deletions
+25
View File
@@ -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();
}
+5
View File
@@ -9,6 +9,7 @@
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="/_/style.css" /> <link rel="stylesheet" href="/_/style.css" />
<script src="/_/global-shortcuts.js"></script> <script src="/_/global-shortcuts.js"></script>
<script src="/_/page-actions.js"></script>
</head> </head>
<body> <body>
<header> <header>
@@ -24,6 +25,10 @@
{{else if .CanEdit}} {{else if .CanEdit}}
<button class="btn" onclick="newPage()" title="New page (N)">NEW</button> <button class="btn" onclick="newPage()" title="New page (N)">NEW</button>
<a class="btn" href="?edit" title="Edit page (E)">EDIT</a> <a class="btn" href="?edit" title="Edit page (E)">EDIT</a>
{{if not .IsRoot}}
<button class="btn" onclick="movePage()" title="Move page">MOVE</button>
<button class="btn danger" onclick="deletePage()" title="Delete page">DELETE</button>
{{end}}
{{end}} {{end}}
</header> </header>
<main> <main>
+17
View File
@@ -59,6 +59,15 @@ a:hover {
color: var(--link-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 === */
header { header {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
@@ -124,6 +133,14 @@ header {
padding: 0 0.15rem; padding: 0 0.15rem;
} }
/* Destructive action */
.danger {
color: var(--primary-hover);
}
.danger:hover {
color: var(--link-hover);
}
/* === Main === */ /* === Main === */
main { main {
max-width: 860px; max-width: 860px;
+13
View File
@@ -51,6 +51,8 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
initMarkdown(root)
authKey, err := loadOrCreateAuthKey(root) authKey, err := loadOrCreateAuthKey(root)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -183,6 +185,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
Crumbs: buildCrumbs(urlPath), Crumbs: buildCrumbs(urlPath),
CanEdit: true, CanEdit: true,
EditMode: editMode, EditMode: editMode,
IsRoot: urlPath == "/",
SectionIndex: sectionIndex, SectionIndex: sectionIndex,
PostURL: urlPath, PostURL: urlPath,
RawContent: rawContent, 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) { 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 { if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
+219
View File
@@ -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
}
+13 -5
View File
@@ -15,11 +15,18 @@ import (
"github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/renderer/html"
) )
var md = goldmark.New( var md goldmark.Markdown
goldmark.WithExtensions(extension.GFM, extension.Table),
goldmark.WithParserOptions(parser.WithAutoHeadingID()), // initMarkdown builds the package-level goldmark instance. Called once from
goldmark.WithRendererOptions(html.WithUnsafe()), // 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 crumb struct{ Name, URL string }
type entry struct { type entry struct {
@@ -32,6 +39,7 @@ type pageData struct {
Crumbs []crumb Crumbs []crumb
CanEdit bool CanEdit bool
EditMode bool EditMode bool
IsRoot bool
SectionIndex int // -1 = whole page; >=0 = section being edited SectionIndex int // -1 = whole page; >=0 = section being edited
PostURL string PostURL string
RawContent string RawContent string
+186
View File
@@ -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(`<a href="`)
w.WriteString(href)
w.WriteString(`"`)
if broken {
w.WriteString(` class="broken"`)
}
w.WriteString(`>`)
w.Write(util.EscapeHTML([]byte(display)))
w.WriteString(`</a>`)
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),
))
}