diff --git a/assets/anchors.js b/assets/anchors.js
new file mode 100644
index 0000000..762e7af
--- /dev/null
+++ b/assets/anchors.js
@@ -0,0 +1,11 @@
+(function () {
+ document.querySelectorAll('.content h1, .content h2, .content h3, .content h4, .content h5, .content h6').forEach(function (h) {
+ if (!h.id) return;
+ var a = document.createElement('a');
+ a.href = '#' + h.id;
+ a.className = 'heading-anchor';
+ a.setAttribute('aria-label', 'Link to this section');
+ a.textContent = '§';
+ h.insertBefore(a, h.firstChild);
+ });
+}());
diff --git a/assets/page.html b/assets/page.html
index d368cb7..800ac6a 100644
--- a/assets/page.html
+++ b/assets/page.html
@@ -81,6 +81,7 @@
{{end}}
{{if or .Content .SpecialContent}}
+
{{end}}
{{if .Content}}
diff --git a/assets/style.css b/assets/style.css
index 65f5530..fcfa167 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -234,6 +234,15 @@ main {
max-width: 100%;
}
+.heading-anchor {
+ color: var(--text-muted);
+ margin-right: 0.4em;
+ font-weight: normal;
+}
+.heading-anchor:hover {
+ color: var(--primary-hover);
+}
+
/* === File listing === */
.listing {
border: 1px solid var(--secondary);
diff --git a/assets/toc.js b/assets/toc.js
index 978ee3a..bc1affa 100644
--- a/assets/toc.js
+++ b/assets/toc.js
@@ -21,7 +21,7 @@
var a = document.createElement("a");
a.href = "#" + h.id;
var clone = h.cloneNode(true);
- clone.querySelectorAll(".btn, .muted").forEach(function (el) { el.remove(); });
+ clone.querySelectorAll(".btn, .muted, .heading-anchor").forEach(function (el) { el.remove(); });
a.textContent = clone.textContent.trim();
li.appendChild(a);
list.appendChild(li);
diff --git a/main.go b/main.go
index d2b4268..a30a81d 100644
--- a/main.go
+++ b/main.go
@@ -243,6 +243,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
}
content := r.FormValue("content")
indexPath := filepath.Join(fsPath, "index.md")
+ redirectTarget := urlPath
// If a section index was submitted, splice the edited section back into
// the full file rather than replacing the whole document.
@@ -258,6 +259,15 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
sections[sectionIndex] = []byte(content)
}
content = string(joinSections(sections))
+ // Section index ≥ 1 is a heading-anchored section. Redirect to its
+ // anchor so the user lands on the section they just saved, even if
+ // the heading text changed.
+ if sectionIndex >= 1 {
+ ids := headingIDs([]byte(content))
+ if sectionIndex-1 < len(ids) {
+ redirectTarget = urlPath + "#" + ids[sectionIndex-1]
+ }
+ }
}
if strings.TrimSpace(content) == "" {
@@ -275,7 +285,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
return
}
}
- http.Redirect(w, r, urlPath, http.StatusSeeOther)
+ http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
}
// readPageSettings parses a .page-settings file in dir.
diff --git a/sections.go b/sections.go
index 4b26820..983df77 100644
--- a/sections.go
+++ b/sections.go
@@ -3,6 +3,9 @@ package main
import (
"bytes"
"regexp"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/text"
)
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
@@ -25,6 +28,29 @@ func splitSections(raw []byte) [][]byte {
return sections
}
+// headingIDs returns the auto-generated id of every heading in raw markdown,
+// in document order. The kth heading (1-indexed) corresponds to section k from
+// splitSections. Uses the package-level goldmark parser so duplicate-id
+// numbering matches what the renderer emits.
+func headingIDs(raw []byte) []string {
+ doc := md.Parser().Parse(text.NewReader(raw))
+ var ids []string
+ ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
+ if !entering {
+ return ast.WalkContinue, nil
+ }
+ if _, ok := n.(*ast.Heading); ok {
+ if v, ok := n.AttributeString("id"); ok {
+ if b, ok := v.([]byte); ok {
+ ids = append(ids, string(b))
+ }
+ }
+ }
+ return ast.WalkContinue, nil
+ })
+ return ids
+}
+
// joinSections reassembles sections produced by splitSections.
// Inserts a newline between sections when a non-empty section lacks a
// trailing newline, so an edited section cannot inline the next heading.