Improve sections
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{if or .Content .SpecialContent}}
|
{{if or .Content .SpecialContent}}
|
||||||
<script src="/_/content.js"></script>
|
<script src="/_/content.js"></script>
|
||||||
|
<script src="/_/anchors.js"></script>
|
||||||
<script src="/_/toc.js"></script>
|
<script src="/_/toc.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
|
|||||||
@@ -234,6 +234,15 @@ main {
|
|||||||
max-width: 100%;
|
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 === */
|
/* === File listing === */
|
||||||
.listing {
|
.listing {
|
||||||
border: 1px solid var(--secondary);
|
border: 1px solid var(--secondary);
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
a.href = "#" + h.id;
|
a.href = "#" + h.id;
|
||||||
var clone = h.cloneNode(true);
|
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();
|
a.textContent = clone.textContent.trim();
|
||||||
li.appendChild(a);
|
li.appendChild(a);
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
}
|
}
|
||||||
content := r.FormValue("content")
|
content := r.FormValue("content")
|
||||||
indexPath := filepath.Join(fsPath, "index.md")
|
indexPath := filepath.Join(fsPath, "index.md")
|
||||||
|
redirectTarget := urlPath
|
||||||
|
|
||||||
// If a section index was submitted, splice the edited section back into
|
// If a section index was submitted, splice the edited section back into
|
||||||
// the full file rather than replacing the whole document.
|
// 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)
|
sections[sectionIndex] = []byte(content)
|
||||||
}
|
}
|
||||||
content = string(joinSections(sections))
|
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) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
@@ -275,7 +285,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, urlPath, http.StatusSeeOther)
|
http.Redirect(w, r, redirectTarget, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readPageSettings parses a .page-settings file in dir.
|
// readPageSettings parses a .page-settings file in dir.
|
||||||
|
|||||||
+26
@@ -3,6 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
|
"github.com/yuin/goldmark/text"
|
||||||
)
|
)
|
||||||
|
|
||||||
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
|
||||||
@@ -25,6 +28,29 @@ func splitSections(raw []byte) [][]byte {
|
|||||||
return sections
|
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.
|
// joinSections reassembles sections produced by splitSections.
|
||||||
// Inserts a newline between sections when a non-empty section lacks a
|
// Inserts a newline between sections when a non-empty section lacks a
|
||||||
// trailing newline, so an edited section cannot inline the next heading.
|
// trailing newline, so an edited section cannot inline the next heading.
|
||||||
|
|||||||
Reference in New Issue
Block a user