From 73a8b4f78ff858affc6de431cb297c59a8063825 Mon Sep 17 00:00:00 2001 From: luxick Date: Tue, 28 Apr 2026 17:43:20 +0200 Subject: [PATCH] Improve sections --- assets/anchors.js | 11 +++++++++++ assets/page.html | 1 + assets/style.css | 9 +++++++++ assets/toc.js | 2 +- main.go | 12 +++++++++++- sections.go | 26 ++++++++++++++++++++++++++ 6 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 assets/anchors.js 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.