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 }