From 5ad715426d8821412be48f40c37972480518ac15 Mon Sep 17 00:00:00 2001 From: luxick Date: Sun, 23 Aug 2026 14:25:12 +0200 Subject: [PATCH] Update section behavior --- assets/editor/main.html | 1 + assets/page/sections.js | 13 +++++++++---- diary.go | 17 ----------------- main.go | 29 +++++++++++++++++++++-------- render.go | 1 + sections.go | 34 ++++++++++++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 29 deletions(-) diff --git a/assets/editor/main.html b/assets/editor/main.html index 7434597..d934e2d 100644 --- a/assets/editor/main.html +++ b/assets/editor/main.html @@ -9,6 +9,7 @@
{{if ge .SectionIndex 0}}{{end}} + {{if .Span}}{{end}} {{if ge .InsertBefore 0}}{{end}}
diff --git a/assets/page/sections.js b/assets/page/sections.js index fe2bd4b..28949ad 100644 --- a/assets/page/sections.js +++ b/assets/page/sections.js @@ -4,13 +4,18 @@ var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6'); if (!headings.length) return; - // Section 0 is pre-heading content, editable via full-page edit. - // Sections 1..N each start at a heading; that is the index sent to the server. - // Skip headings that already carry a server-rendered edit link. headings.forEach(function (h, i) { if (h.querySelector('a.btn')) return; var a = document.createElement('a'); - a.href = '?edit§ion=' + (i + 1); + + if (h.tagName === 'H1') { + a.href = '?edit'; + } else if (h.tagName === 'H2') { + a.href = '?edit§ion=' + (i + 1) + '&span'; + } else { + return; + } + a.className = 'btn btn-small btn-float'; a.textContent = 'edit'; h.appendChild(document.createTextNode(' ')) diff --git a/diary.go b/diary.go index 82fba13..a42bbf3 100644 --- a/diary.go +++ b/diary.go @@ -10,7 +10,6 @@ import ( "os" "path" "path/filepath" - "regexp" "sort" "strconv" "strings" @@ -208,22 +207,6 @@ func findDiaryContext(root, fsPath, urlPath string) (depth int, diaryRootFS, dia return 0, "", "", false } -// headingTextRe matches an ATX heading at the start of a section. The -// heading text is everything after the `#`s and the required space, on the -// first line. -var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`) - -// sectionHeading returns the heading level (1..6) and trimmed text of a -// section produced by splitSections. Returns level=0 for the pre-heading -// section (index 0). -func sectionHeading(section []byte) (level int, text string) { - m := headingTextRe.FindSubmatch(section) - if m == nil { - return 0, "" - } - return len(m[1]), strings.TrimSpace(string(m[2])) -} - // findSectionIndex returns the absolute section index whose heading text // matches target (e.g. "2026-05" or "2026-05-28"). Returns the first match. func findSectionIndex(sections [][]byte, target string) (int, bool) { diff --git a/main.go b/main.go index 74d03ea..924b2a6 100644 --- a/main.go +++ b/main.go @@ -261,6 +261,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa // Determine section index (-1 = whole page). sectionIndex := -1 insertBefore := -1 + sectionSpan := false if editMode { if s := r.URL.Query().Get("section"); s != "" { if n, err := strconv.Atoi(s); err == nil && n >= 0 { @@ -272,6 +273,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa insertBefore = n } } + sectionSpan = r.URL.Query().Has("span") } var special *specialPage @@ -323,7 +325,11 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa } else if editMode && sectionIndex >= 0 { sections := splitSections(rawMD) if sectionIndex < len(sections) { - rawContent = string(sections[sectionIndex]) + end := sectionIndex + 1 + if sectionSpan { + end = secionSpanEnd(sections, sectionIndex) + } + rawContent = string(joinSections(sections[sectionIndex:end])) } } else if editMode && rawContent == "" && urlPath != "/" { rawContent = "# " + pageTitle(urlPath) + "\n\n" @@ -336,6 +342,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa IsRoot: urlPath == "/", SectionIndex: sectionIndex, InsertBefore: insertBefore, + Span: sectionSpan, PostURL: urlPath, RawContent: rawContent, Content: rendered, @@ -390,11 +397,7 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs indexPath := filepath.Join(fsPath, "index.md") redirectTarget := urlPath - // insert_before splices a new section into the file *at* index N rather - // than replacing index N (used by the diary "create new day" flow). - // section replaces the section at index N (used by per-section edits). - // Exactly one of insert_before / section should be set; insert_before - // wins if both are present. + // handle section saving if s := r.FormValue("insert_before"); s != "" { insertIndex, err := strconv.Atoi(s) if err != nil || insertIndex < 0 { @@ -431,8 +434,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs http.Error(w, "section no longer exists — the page changed since you opened the editor", http.StatusConflict) return } - sections[sectionIndex] = []byte(content) - content = string(joinSections(sections)) + + // handling of section editing that spans until the next heading of the same level + end := sectionIndex + 1 + if r.FormValue("span") != "" { + end = secionSpanEnd(sections, sectionIndex) + } + merged := make([][]byte, 0, len(sections)-(end-sectionIndex)+1) + merged = append(merged, sections[:sectionIndex]...) + merged = append(merged, []byte(content)) + merged = append(merged, sections[end:]...) + content = string(joinSections(merged)) + // 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. diff --git a/render.go b/render.go index 4ffe155..3bb5664 100644 --- a/render.go +++ b/render.go @@ -52,6 +52,7 @@ type pageData struct { IsRoot bool SectionIndex int // -1 = whole page; >=0 = section being edited InsertBefore int // -1 = no insert; >=0 = splice new section at this index + Span bool // causes section editing to also cover every sub-section to the next section of the same level PostURL string RawContent string Content template.HTML diff --git a/sections.go b/sections.go index 983df77..884f80e 100644 --- a/sections.go +++ b/sections.go @@ -3,6 +3,7 @@ package main import ( "bytes" "regexp" + "strings" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/text" @@ -10,6 +11,11 @@ import ( var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `) +// headingTextRe matches an ATX heading at the start of a section. The +// heading text is everything after the `#`s and the required space, on the +// first line. +var headingTextRe = regexp.MustCompile(`^(#{1,6})\s+([^\n]*)`) + // splitSections splits raw markdown into sections. // Section 0 is any content before the first heading. // Each subsequent section begins at a heading line and runs to the next. @@ -64,3 +70,31 @@ func joinSections(sections [][]byte) []byte { } return buf.Bytes() } + +// sectionHeading returns the heading level (1..6) and trimmed text of a +// section produced by splitSections. Returns level=0 for the pre-heading +// section (index 0). +func sectionHeading(section []byte) (level int, text string) { + m := headingTextRe.FindSubmatch(section) + if m == nil { + return 0, "" + } + return len(m[1]), strings.TrimSpace(string(m[2])) +} + +// sectionSpanEnd returns the exclusive end index of the section span starting +// at start: the first following section whose heading level is <= start's. +// Sections nested deeper than start (its subsections) belong to the span, so +// editing a `##` covers every `###`+ under it up to the next `##` or `#`. +func secionSpanEnd(sections [][]byte, start int) int { + level, _ := sectionHeading(sections[start]) + if level == 0 { + return start + 1 + } + for i := start + 1; i <= len(sections); i++ { + if l, _ := sectionHeading(sections[i]); l > 0 && l <= level { + return i + } + } + return len(sections) +}