diff --git a/assets/page.html b/assets/page.html
index e628f45..0af04b4 100644
--- a/assets/page.html
+++ b/assets/page.html
@@ -11,6 +11,7 @@
+
{{end}}
{{if .Content}}
diff --git a/assets/style.css b/assets/style.css
index 1b324a7..1a4c66a 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -439,6 +439,12 @@ textarea {
font-size: 0.85rem;
}
+/* === Task lists === */
+.content li:has(> input.task-checkbox:checked) {
+ color: var(--text-muted);
+ text-decoration: line-through;
+}
+
/* === Photo grid === */
.photo-grid {
display: grid;
diff --git a/assets/tasks.js b/assets/tasks.js
new file mode 100644
index 0000000..180ce36
--- /dev/null
+++ b/assets/tasks.js
@@ -0,0 +1,24 @@
+(function () {
+ document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
+ cb.addEventListener('change', function () {
+ var idx = cb.dataset.taskIndex;
+ var checked = cb.checked;
+ cb.disabled = true;
+ fetch(window.location.pathname + '?toggle=' + idx, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: 'checked=' + checked
+ }).then(function (res) {
+ if (!res.ok) {
+ cb.checked = !checked;
+ alert('Failed to save task state (' + res.status + ')');
+ }
+ }).catch(function () {
+ cb.checked = !checked;
+ alert('Failed to save task state');
+ }).finally(function () {
+ cb.disabled = false;
+ });
+ });
+ });
+})();
diff --git a/main.go b/main.go
index 25ba057..a21b528 100644
--- a/main.go
+++ b/main.go
@@ -249,6 +249,10 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
h.handleMove(w, r, urlPath, fsPath, query.Get("move"))
return
}
+ if query.Has("toggle") {
+ h.handleToggle(w, r, fsPath)
+ return
+ }
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
diff --git a/render.go b/render.go
index fc4aa53..eba39ed 100644
--- a/render.go
+++ b/render.go
@@ -70,7 +70,7 @@ func renderMarkdown(raw []byte) template.HTML {
if err := md.Convert(raw, &buf); err != nil {
return ""
}
- return template.HTML(buf.String())
+ return template.HTML(rewriteTaskCheckboxes(buf.Bytes()))
}
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
diff --git a/tasks.go b/tasks.go
new file mode 100644
index 0000000..3cf5e15
--- /dev/null
+++ b/tasks.go
@@ -0,0 +1,112 @@
+package main
+
+import (
+ "bytes"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+)
+
+// taskCheckboxRe matches the tag goldmark's GFM extension emits for a
+// task list checkbox. Used to enumerate and rewrite checkboxes in rendered HTML.
+var taskCheckboxRe = regexp.MustCompile(``)
+
+// 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(`')
+ 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
+}