Allow checking off tasks from rendered pages

This commit is contained in:
2026-05-04 11:37:38 +02:00
parent 80f6abcbaa
commit 4c55bd050f
6 changed files with 148 additions and 1 deletions
+112
View File
@@ -0,0 +1,112 @@
package main
import (
"bytes"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
)
// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
// task list checkbox. Used to enumerate and rewrite checkboxes in rendered HTML.
var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type="checkbox">`)
// taskLineRe matches a markdown task list line: leading whitespace, a bullet,
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)
// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
// its position among task list items in source order; the disabled attribute
// is removed so the user can toggle them.
func rewriteTaskCheckboxes(in []byte) []byte {
idx := 0
return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
checked := bytes.Contains(match, []byte("checked"))
var out bytes.Buffer
out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
out.WriteString(strconv.Itoa(idx))
out.WriteByte('"')
if checked {
out.WriteString(` checked=""`)
}
out.WriteByte('>')
idx++
return out.Bytes()
})
}
// handleToggle flips the Nth task list checkbox in index.md based on the
// `toggle` query param and `checked` form value. Indices match the order in
// which goldmark emits checkboxes, which is source order excluding fenced
// code blocks.
func (h *handler) handleToggle(w http.ResponseWriter, r *http.Request, fsPath string) {
n, err := strconv.Atoi(r.URL.Query().Get("toggle"))
if err != nil || n < 0 {
http.Error(w, "bad toggle index", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
checked := r.FormValue("checked") == "true"
indexPath := filepath.Join(fsPath, "index.md")
raw, err := os.ReadFile(indexPath)
if err != nil {
http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
return
}
updated, ok := flipTaskLine(raw, n, checked)
if !ok {
http.Error(w, "task not found", http.StatusNotFound)
return
}
if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// flipTaskLine returns raw with the Nth task list bullet's `[ ]`/`[x]` marker
// set according to checked. Lines inside fenced code blocks are skipped so
// they do not consume an index. Returns ok=false when there is no Nth task.
func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) {
lines := bytes.Split(raw, []byte("\n"))
inFence := false
count := 0
target := -1
for i, line := range lines {
trimmed := bytes.TrimLeft(line, " \t")
if bytes.HasPrefix(trimmed, []byte("```")) || bytes.HasPrefix(trimmed, []byte("~~~")) {
inFence = !inFence
continue
}
if inFence {
continue
}
if !taskLineRe.Match(line) {
continue
}
if count == n {
target = i
break
}
count++
}
if target == -1 {
return nil, false
}
replacement := []byte("${1}[ ]")
if checked {
replacement = []byte("${1}[x]")
}
lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
return bytes.Join(lines, []byte("\n")), true
}