feat: auto-interactive markdown checkboxes#145
Conversation
Markdown task lists (- [ ] text) under headings automatically become interactive checkboxes — zero configuration required. Changes persist to the markdown file via the existing MarkdownSource pipeline. Pre-processing transforms in-memory markdown to inject lvt code blocks and source configs, reusing the entire existing pipeline unchanged. Watcher detects same-file sources to avoid full page reloads on toggle. Also fixes Docker Chrome E2E setup on macOS (port mapping instead of --network host which Docker Desktop doesn't support). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add writeMu to WebSocketHandler to prevent concurrent writes to the websocket connection (watcher refresh racing with action response caused panic). Fix Add button taking full width by adding width: auto. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces “zero-config” interactive Markdown task lists by auto-detecting task-list-only sections under headings and transforming them (in-memory) into lvt blocks wired to the existing markdown source persistence pipeline. It also adjusts live-reload behavior to avoid full page reloads on checkbox toggles, serializes WebSocket writes to prevent concurrent write panics, and updates Docker Chrome E2E helpers for macOS.
Changes:
- Add markdown preprocessor to auto-detect task list sections and inject
lvtblocks + auto-generated markdown sources. - Update watcher logic to refresh sources (instead of full reload) for same-file sources and add WebSocket write serialization.
- Add auto-tasks example plus unit/E2E coverage; update Docker Chrome E2E setup for macOS.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
page.go |
Runs auto-task preprocessing before markdown parsing and injects auto-generated sources into the page config. |
auto_tasks.go |
Implements task-section detection, preprocessing into lvt blocks, and heading slugification. |
auto_tasks_test.go |
Adds unit tests for detection, preprocessing, slugification, and block generation. |
auto_tasks_e2e_test.go |
Adds E2E tests validating toggle/add/persist and “no full reload” behavior. |
internal/server/websocket.go |
Adds writeMu to serialize WebSocket writes and exposes TracksSourceFile for watcher decisions. |
internal/server/server.go |
Adjusts watcher behavior to refresh sources for same-file sources and adds tracked-source-file detection. |
e2e_helpers_test.go |
Switches Docker Chrome networking strategy on macOS to port mapping and updates URL conversion. |
e2e_helpers_external_test.go |
Same Docker Chrome macOS changes for external tests package. |
examples/auto-tasks/index.md |
Adds a simple example page demonstrating zero-config task lists. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Re-discover pages for page file changes | ||
| if err := s.Discover(); err != nil { | ||
| return fmt.Errorf("failed to re-discover pages: %w", err) |
There was a problem hiding this comment.
Even with writeMu in WebSocketHandler, there’s still an un-serialized WebSocket write path: Server.BroadcastReload calls conn.WriteMessage directly (outside the handler), which can still race with handler.sendMessage writes and trigger gorilla/websocket concurrent write panics. Consider ensuring reload broadcasts also go through the same per-connection write serialization mechanism.
internal/server/server.go
Outdated
| if isPageFile && isSourceFile { | ||
| // Same-file source (e.g., auto-tasks): refresh sources only, no full reload. | ||
| // This prevents a full page reload on every checkbox toggle. | ||
| s.RefreshSourcesForFile(filePath) | ||
| } else if isPageFile { |
There was a problem hiding this comment.
In EnableWatch, treating any page file that is also tracked as a source as “refresh sources only” means normal edits to that markdown page (adding/removing headings/blocks, changing non-task content) will no longer trigger Discover()+BroadcastReload, so the served page can get stuck showing stale HTML. Consider narrowing this optimization to writes that originate from the auto-task persistence path (e.g., track recently-written files/timestamps) or otherwise still perform a full reload when the page structure/content might have changed.
| if isPageFile && isSourceFile { | |
| // Same-file source (e.g., auto-tasks): refresh sources only, no full reload. | |
| // This prevents a full page reload on every checkbox toggle. | |
| s.RefreshSourcesForFile(filePath) | |
| } else if isPageFile { | |
| if isPageFile { | |
| // For page files, always re-discover and reload so that structural/content | |
| // changes are reflected in the served HTML. | |
| // If the page file also contains tracked sources (e.g., auto-tasks), | |
| // refresh those sources first so clients see the latest task state. | |
| if isSourceFile { | |
| s.RefreshSourcesForFile(filePath) | |
| } |
| sec := sections[i] | ||
| sourceName := "_auto_" + sec.anchor | ||
|
|
||
| // Skip if this anchor already has a manually configured source | ||
| // (checked later during injection in ParseFile) | ||
|
|
||
| // Build the lvt code block replacement | ||
| lvtBlock := generateAutoTaskLvtBlock(sourceName) | ||
|
|
||
| // Build replacement lines: heading + lvt code block | ||
| var replacement []string | ||
| // Keep the heading line | ||
| headingLine := lines[sec.startLine-1+fmLineCount] | ||
| replacement = append(replacement, headingLine) | ||
| replacement = append(replacement, "") | ||
| replacement = append(replacement, "```lvt") | ||
| replacement = append(replacement, lvtBlock) | ||
| replacement = append(replacement, "```") | ||
|
|
||
| // Replace lines: heading line through end of task items | ||
| startIdx := sec.startLine - 1 + fmLineCount // heading line | ||
| endIdx := sec.endLine + fmLineCount // exclusive | ||
|
|
||
| newLines := make([]string, 0, len(lines)-(endIdx-startIdx)+len(replacement)) | ||
| newLines = append(newLines, lines[:startIdx]...) | ||
| newLines = append(newLines, replacement...) | ||
| newLines = append(newLines, lines[endIdx:]...) | ||
| lines = newLines | ||
|
|
||
| // Build source config pointing to the original file | ||
| readonlyFalse := false | ||
| sources[sourceName] = SourceConfig{ | ||
| Type: "markdown", | ||
| File: absPath, | ||
| Anchor: "#" + sec.anchor, | ||
| Readonly: &readonlyFalse, |
There was a problem hiding this comment.
slugifyHeading can return an empty string for headings that contain no [a-z0-9] characters, which would produce sourceName "auto" and Anchor "#". That can cause collisions between sections and makes anchor matching ambiguous. Consider validating sec.anchor and either skipping such sections or falling back to a deterministic non-empty name (e.g., based on heading hash/line).
auto_tasks.go
Outdated
| // Check for explicit anchor syntax {#anchor} | ||
| explicitAnchorRe := regexp.MustCompile(`\s*\{#([^}]+)\}\s*$`) | ||
| if anchorMatch := explicitAnchorRe.FindStringSubmatch(headingText); anchorMatch != nil { |
There was a problem hiding this comment.
This regexp is compiled on every heading line encountered. Since detectTaskListSections can run on every page parse, it would be better to precompile the explicit anchor regexp once (similar to the other patterns) to avoid repeated compilation overhead.
e2e_helpers_test.go
Outdated
| if runtime.GOOS == "darwin" { | ||
| // macOS: use port mapping, don't override the debugging port | ||
| cmd = exec.Command("docker", "run", "-d", | ||
| "--rm", | ||
| "-p", fmt.Sprintf("%d:9222", debugPort), | ||
| "--memory", "512m", | ||
| "--cpus", "0.5", | ||
| "--name", containerName, | ||
| dockerImage, | ||
| ) | ||
| } else { | ||
| // Linux: use --network host for reliable localhost access | ||
| cmd = exec.Command("docker", "run", "-d", | ||
| "--rm", | ||
| "--network", "host", | ||
| "--memory", "512m", | ||
| "--cpus", "0.5", | ||
| "--name", containerName, | ||
| dockerImage, | ||
| "--remote-debugging-port="+fmt.Sprintf("%d", debugPort), | ||
| ) |
There was a problem hiding this comment.
The Docker networking workaround is applied only for darwin. Docker Desktop on Windows also doesn’t support --network host, and Chrome-in-Docker typically needs host.docker.internal + -p port mapping there as well. Consider extending the OS check to treat all non-Linux platforms similarly (or explicitly handle windows) to keep E2E helpers cross-platform.
e2e_helpers_external_test.go
Outdated
| if runtime.GOOS == "darwin" { | ||
| // macOS: use port mapping, don't override the debugging port | ||
| cmd = exec.Command("docker", "run", "-d", | ||
| "--rm", | ||
| "-p", fmt.Sprintf("%d:9222", debugPort), | ||
| "--memory", "512m", | ||
| "--cpus", "0.5", | ||
| "--name", containerName, | ||
| dockerImage, | ||
| ) | ||
| } else { | ||
| // Linux: use --network host for reliable localhost access | ||
| cmd = exec.Command("docker", "run", "-d", | ||
| "--rm", | ||
| "--network", "host", | ||
| "--memory", "512m", | ||
| "--cpus", "0.5", | ||
| "--name", containerName, | ||
| dockerImage, | ||
| "--remote-debugging-port="+fmt.Sprintf("%d", debugPort), |
There was a problem hiding this comment.
Same as e2e_helpers_test.go: limiting the Docker networking behavior to darwin will likely break E2E runs on Windows (no --network host, and host.docker.internal is typically required). Consider handling windows (or all non-Linux OSes) the same way as macOS.
| if runtime.GOOS == "darwin" { | |
| // macOS: use port mapping, don't override the debugging port | |
| cmd = exec.Command("docker", "run", "-d", | |
| "--rm", | |
| "-p", fmt.Sprintf("%d:9222", debugPort), | |
| "--memory", "512m", | |
| "--cpus", "0.5", | |
| "--name", containerName, | |
| dockerImage, | |
| ) | |
| } else { | |
| // Linux: use --network host for reliable localhost access | |
| cmd = exec.Command("docker", "run", "-d", | |
| "--rm", | |
| "--network", "host", | |
| "--memory", "512m", | |
| "--cpus", "0.5", | |
| "--name", containerName, | |
| dockerImage, | |
| "--remote-debugging-port="+fmt.Sprintf("%d", debugPort), | |
| if runtime.GOOS == "linux" { | |
| // Linux: use --network host for reliable localhost access | |
| cmd = exec.Command("docker", "run", "-d", | |
| "--rm", | |
| "--network", "host", | |
| "--memory", "512m", | |
| "--cpus", "0.5", | |
| "--name", containerName, | |
| dockerImage, | |
| "--remote-debugging-port="+fmt.Sprintf("%d", debugPort), | |
| ) | |
| } else { | |
| // Non-Linux (e.g., macOS, Windows): use port mapping, don't override the debugging port | |
| cmd = exec.Command("docker", "run", "-d", | |
| "--rm", | |
| "-p", fmt.Sprintf("%d:9222", debugPort), | |
| "--memory", "512m", | |
| "--cpus", "0.5", | |
| "--name", containerName, | |
| dockerImage, |
Both branches independently fixed macOS Docker Chrome. Take origin/main version which uses cleaner args slice pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pull Request Review: Auto-Interactive Markdown CheckboxesSummaryThis PR implements a clean zero-configuration feature that automatically transforms markdown task lists into interactive checkboxes. The implementation is well-architected, making clever use of in-memory preprocessing to reuse the existing LiveTemplate pipeline without modifying files on disk. Code Quality & Architecture ✅Strengths:
Suggestions:
if !bytes.Contains(content, []byte("- [ ]")) && !bytes.Contains(content, []byte("- [x]")) && !bytes.Contains(content, []byte("- [X]")) {This won't detect tasks with extra whitespace like
explicitAnchorRe := regexp.MustCompile(`\s*\{#([^}]+)\}\s*$`)This regex is compiled on every heading encountered. Move to package-level
// This must match the slugify() function in internal/source/markdown.go for anchor matching.
func slugifyHeading(text string) string {The comment warns about keeping this in sync, but there's no enforcement. Consider:
Potential Bugs 🐛Critical Issues:1. Race condition in line index calculation (auto_tasks.go:142-170) The code processes sections in reverse order but the line adjustment logic is complex: for i := len(sections) - 1; i >= 0; i-- {
sec := sections[i]
// ... build replacement ...
startIdx := sec.startLine - 1 + fmLineCount
endIdx := sec.endLine + fmLineCountIssue: When multiple sections exist, modifying one section changes line numbers for others. Even processing in reverse, the Test case that might fail: A file with 3+ task sections where the first section is very long. After processing the third section, the line indices for the second section may be off. Recommendation: Either:
2. Frontmatter detection (auto_tasks.go:122-124) if loc := frontmatterPattern.FindIndex(content); loc != nil {
bodyOffset = loc[1]
}The Fix: Use 3. Missing validation in preprocessAutoTasks (auto_tasks.go:142-149) There's no check if a source name already exists before creating Example: ## Tasks
- [ ] Item 1
## TASKS
- [ ] Item 2Both slug to "tasks", so only one Fix: Detect collisions and either skip, append a number suffix, or log a warning. Minor Issues:4. No error handling for malformed headings (auto_tasks.go:74-88) The explicit anchor regex could match malformed syntax like 5. Template injection risk (auto_tasks.go:186-202) The return fmt.Sprintf(`<div lvt-source="%s">`, sourceName)While Performance Considerations ⚡Good:
Concerns:
Security Concerns 🔒Medium Risk: 1. Arbitrary file path in SourceConfig (auto_tasks.go:176) File: absPath,While Recommendation: Add path validation to ensure files are within the allowed directory tree. Low Risk: 2. No input sanitization on anchor IDs User-controlled heading text becomes anchor IDs. While slugification removes most dangerous chars, Unicode or very long headings could cause issues downstream. Test Coverage ✅Excellent coverage overall! Unit Tests (auto_tasks_test.go):
E2E Tests (auto_tasks_e2e_test.go):
Missing test cases:
WebSocket Concurrency Fix 🎯Excellent fix! (websocket.go:142, 1158-1161) Adding
Suggestion: Consider using a buffered channel-based write queue instead of a mutex if you expect high-frequency concurrent writes. The current mutex approach is simpler and sufficient for now. Watcher Same-File Source Detection 🎯Smart optimization! (server.go:2405-2429, 2418-2429) The Minor issue: The O(n×m) complexity (connections × sources per connection) could be optimized with a reverse index ( Breaking Changes / API ImpactNone - This is purely additive. Existing markdown files work unchanged, and the feature activates only when task lists are detected. DocumentationThe PR description is excellent and comprehensive. Consider adding:
Recommendations SummaryMust Fix (before merge):
Should Fix (before merge): Nice to Have: Overall AssessmentRecommendation: Approve with minor fixes ✅ This is a well-designed feature with excellent test coverage and thoughtful implementation. The preprocessing approach is elegant, and the watcher optimization shows deep understanding of the system. The issues identified are mostly edge cases and minor improvements, not fundamental flaws. The code quality is high, the tests are comprehensive, and the feature delivers real value with zero configuration. Great work! Review Score:
🤖 Generated with Claude Code |
- Precompile explicit anchor regexp (was compiled per heading line) - Skip sections with empty anchor slugs to prevent collisions - Track source writes so watcher distinguishes source actions (toggle) from external edits — external edits still trigger full page reload - Route BroadcastReload through handler's writeMu to prevent concurrent websocket write panics Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pull Request Review: Auto-Interactive Markdown CheckboxesGreat work on this feature! The implementation is clean, well-tested, and follows good architectural patterns. ✅ StrengthsCode Quality & Architecture
Concurrency & Thread Safety
Implementation Details
🔍 Issues & Concerns1. Potential Race Condition in isRecentSourceWrite() The entry is deleted on first read. If the watcher fires twice quickly for the same file (which can happen with some filesystems), the second check will return false and trigger a full reload. Suggested fix: Keep entry until timeout to allow multiple watcher events, and add cleanup goroutine to prevent unbounded growth. 2. Missing Validation for Empty Anchors Headings like ## !!! or ## 123 will have empty anchors after slugification and be silently ignored. Consider: fall back to generated anchor, log warning, or add test coverage. 3. Hardcoded Timeout Value Extract 2*time.Second to a constant like sourceWriteDebounceWindow. 4. BroadcastReload Race Condition The else branch doesn't use the mutex when handler is nil, which could still cause concurrent write issues. 🔧 Minor Issues
🚀 Performance & Security✅ Early return optimization (auto_tasks.go:31-34) is excellent - avoids regex for files without tasks 🎯 RecommendationsHIGH PRIORITY:
LOW PRIORITY: 📊 Overall AssessmentRating: 8.5/10 This is a solid, well-architected feature that demonstrates good understanding of Go concurrency patterns and careful attention to edge cases. The preprocessing approach is elegant, and the test coverage is comprehensive. The main concerns are around the potential race condition in the debounce logic and ensuring the WebSocket write serialization is complete. The feature delivers excellent user value with its zero-config approach, and the implementation integrates smoothly with the existing pipeline. Recommendation: Approve with minor revisions to address the race condition issues. Great work! 🎉 |
Don't delete recentSourceWrites entry on first read; use time-based expiry instead. This handles the common case where filesystem watchers fire multiple events for the same file change. Extract timeout to sourceWriteDebounceWindow constant. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Review: Auto-Interactive Markdown CheckboxesSummaryThis is a well-designed feature that brings zero-config interactive checkboxes to markdown task lists. The implementation is clean, well-tested, and addresses important edge cases. Strengths1. Excellent Architecture
2. Comprehensive Test Coverage
3. Important Bug Fixes
4. Thoughtful Design Decisions
Areas for Improvement1. XSS Security (Medium Priority)Location: auto_tasks.go:190-205 in generateAutoTaskLvtBlock() The task text is rendered with {{.Text}} which is HTML-escaped by default in Go templates, so this is SAFE. The conditional style is server-controlled, not user-controlled. Recommendation: Add a comment documenting this security consideration for future maintainers. 2. Race Condition in Source Write Tracking (Low Severity)Location: server.go:2251-2262 in isRecentSourceWrite() The debounce cleanup happens inside the check, which could be more robust with proper defer usage. 3. Performance - Early Exit Check (Minor)Location: auto_tasks.go:38-40 The 3 separate Contains calls could be optimized, though this is minor since it runs once per page load. 4. Code Quality Nitpicks
5. Test Coverage Gaps (Low Priority)
Security AssessmentOverall: SECURE
Recommendation: Add integration test verifying XSS protection. Performance AssessmentOverall: EFFICIENT
Code QualityOverall: EXCELLENT
RecommendationsHigh Priority
Medium Priority
Low Priority
ConclusionThis is high-quality work that adds significant value with minimal risk. The implementation is well-thought-out, properly tested, and follows Go best practices. The bug fixes (WebSocket race, watcher optimization) are valuable improvements on their own. Recommendation: APPROVE with minor suggestions for hardening. The zero-config approach is exactly what users want - markdown checkboxes just work without frontmatter setup. Review generated with Claude Code |
Summary
- [ ] text) under headings automatically become interactive checkboxes with zero configuration — no frontmatter, no code blocks, no separate data files--network host)Closes #119
Test plan
go vetclean🤖 Generated with Claude Code