Allow checking off tasks from rendered pages
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
<script src="/_/content.js"></script>
|
||||
<script src="/_/anchors.js"></script>
|
||||
<script src="/_/toc.js"></script>
|
||||
<script src="/_/tasks.js"></script>
|
||||
{{end}}
|
||||
{{if .Content}}
|
||||
<script src="/_/sections.js"></script>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user