Allow editing of individual sections

This commit is contained in:
2026-04-15 12:14:40 +02:00
parent b3ca714597
commit 02fa19272d
7 changed files with 104 additions and 2 deletions

View File

@@ -54,6 +54,8 @@ Prefer separate, human-readable `.html` files over inlined HTML strings in Go. E
- Do not inline JS in templates or merge unrelated features into one file
- `ALT+SHIFT` is the modifier for all keyboard shortcuts — do not introduce others
- Editor toolbar buttons use `data-action` + `data-key`; adding `data-key` auto-registers the shortcut
- Prefer generic, descriptive CSS classes (`btn`, `btn-small`, `muted`, `danger`) over element-specific names (`save-button`, `cancel-button`, `form-name-input`). Use a modifier + base class pattern (`btn btn-small`) rather than one-off classes that duplicate shared styles.
- Where possible, re-use existing CSS classes
## Development Priorities

View File

@@ -26,6 +26,7 @@
<main>
{{if .EditMode}}
<form id="edit-form" class="edit-form" method="POST" action="{{.PostURL}}">
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
<div class="editor-toolbar">
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
@@ -62,6 +63,9 @@
{{if or .Content .SpecialContent}}
<script src="/_/content.js"></script>
{{end}}
{{if .Content}}
<script src="/_/sections.js"></script>
{{end}}
{{if .Entries}}
<div class="listing">
<div class="listing-header">Contents</div>

16
assets/sections.js Normal file
View File

@@ -0,0 +1,16 @@
(function () {
var content = document.querySelector('.content');
if (!content) return;
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.
headings.forEach(function (h, i) {
var a = document.createElement('a');
a.href = '?edit&section=' + (i + 1);
a.className = 'btn btn-small section-edit';
a.textContent = 'edit';
h.appendChild(a);
});
}());

View File

@@ -104,6 +104,13 @@ header {
color: #ffd54f;
}
/* === Button modifiers === */
.btn-small {
font-size: 0.65rem;
font-weight: normal;
vertical-align: middle;
}
/* === Main === */
main {
max-width: 860px;
@@ -331,7 +338,6 @@ textarea:focus {
box-shadow: 0 0 5px #0a0;
}
/* === Diary views === */
.diary-section {
margin: 2rem 0;
@@ -380,6 +386,11 @@ textarea:focus {
margin-bottom: 0.75rem;
}
/* === Section edit links === */
.section-edit {
margin-left: 0.75rem;
}
/* === Empty state === */
.empty {
padding: 1rem;

39
main.go
View File

@@ -10,6 +10,7 @@ import (
"os"
"path"
"path/filepath"
"strconv"
"strings"
)
@@ -123,6 +124,16 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
indexPath := filepath.Join(fsPath, "index.md")
rawMD, _ := os.ReadFile(indexPath)
// Determine section index (-1 = whole page).
sectionIndex := -1
if editMode {
if s := r.URL.Query().Get("section"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
sectionIndex = n
}
}
}
var rendered template.HTML
if len(rawMD) > 0 && !editMode {
rendered = renderMarkdown(rawMD)
@@ -152,13 +163,22 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
specialContent = special.Content
}
rawContent := string(rawMD)
if editMode && sectionIndex >= 0 {
sections := splitSections(rawMD)
if sectionIndex < len(sections) {
rawContent = string(sections[sectionIndex])
}
}
data := pageData{
Title: title,
Crumbs: buildCrumbs(urlPath),
CanEdit: true,
EditMode: editMode,
SectionIndex: sectionIndex,
PostURL: urlPath,
RawContent: string(rawMD),
RawContent: rawContent,
Content: rendered,
Entries: entries,
SpecialContent: specialContent,
@@ -177,6 +197,23 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
}
content := r.FormValue("content")
indexPath := filepath.Join(fsPath, "index.md")
// If a section index was submitted, splice the edited section back into
// the full file rather than replacing the whole document.
if s := r.FormValue("section"); s != "" {
sectionIndex, err := strconv.Atoi(s)
if err != nil || sectionIndex < 0 {
http.Error(w, "bad section", http.StatusBadRequest)
return
}
rawMD, _ := os.ReadFile(indexPath)
sections := splitSections(rawMD)
if sectionIndex < len(sections) {
sections[sectionIndex] = []byte(content)
}
content = string(joinSections(sections))
}
if strings.TrimSpace(content) == "" {
if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)

View File

@@ -32,6 +32,7 @@ type pageData struct {
Crumbs []crumb
CanEdit bool
EditMode bool
SectionIndex int // -1 = whole page; >=0 = section being edited
PostURL string
RawContent string
Content template.HTML

31
sections.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"bytes"
"regexp"
)
var sectionHeadingRe = regexp.MustCompile(`(?m)^#{1,6} `)
// 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.
func splitSections(raw []byte) [][]byte {
locs := sectionHeadingRe.FindAllIndex(raw, -1)
if len(locs) == 0 {
return [][]byte{raw}
}
sections := make([][]byte, 0, len(locs)+1)
prev := 0
for _, loc := range locs {
sections = append(sections, raw[prev:loc[0]])
prev = loc[0]
}
sections = append(sections, raw[prev:])
return sections
}
// joinSections reassembles sections produced by splitSections.
func joinSections(sections [][]byte) []byte {
return bytes.Join(sections, nil)
}