Allow checking off tasks from rendered pages
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
<script src="/_/content.js"></script>
|
<script src="/_/content.js"></script>
|
||||||
<script src="/_/anchors.js"></script>
|
<script src="/_/anchors.js"></script>
|
||||||
<script src="/_/toc.js"></script>
|
<script src="/_/toc.js"></script>
|
||||||
|
<script src="/_/tasks.js"></script>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if .Content}}
|
{{if .Content}}
|
||||||
<script src="/_/sections.js"></script>
|
<script src="/_/sections.js"></script>
|
||||||
|
|||||||
@@ -439,6 +439,12 @@ textarea {
|
|||||||
font-size: 0.85rem;
|
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 === */
|
||||||
.photo-grid {
|
.photo-grid {
|
||||||
display: 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"))
|
h.handleMove(w, r, urlPath, fsPath, query.Get("move"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if query.Has("toggle") {
|
||||||
|
h.handleToggle(w, r, fsPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
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 {
|
if err := md.Convert(raw, &buf); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return template.HTML(buf.String())
|
return template.HTML(rewriteTaskCheckboxes(buf.Bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractFirstHeading returns the text of the first ATX heading in raw markdown,
|
// 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