anvilsign in

collin/anvil · 02fbffce

feat: render TODO.md as a kanban board (todo-md spec)

Collin Richards · 2026-06-10 10:21 UTC · 02fbffce0d2e3994e74163498c7ec58b9b679037 · parent d0d2c17b · browse files

modifiedcrates/anvil-web/src/lib.rs+1 −0
⋯ 24 unchanged lines
2525 pub mod git_http;
2626 pub mod issues;
2727 pub mod pages;
28+pub mod todomd;
2829 pub mod ui;
2930
3031 /// Build the application router.
⋯ 34 unchanged lines
addedcrates/anvil-web/src/todomd.rs+303 −0
1+//! todo-md parsing and kanban rendering.
2+//!
3+//! Implements the todo-md spec (the `todo-md` repo): a `TODO.md` is split
4+//! into sections by ATX headings; `- [ ]` / `- [x]` list items in a section
5+//! are tasks; lines indented under a task are its details (opaque markdown).
6+//! A heading containing `[x]` marks a done section — every task in it counts
7+//! as done. Sections that contain tasks render as kanban columns; prose-only
8+//! sections render as ordinary markdown below the board.
9+//!
10+//! This module is the first "custom renderer" for a well-known filename;
11+//! `ui::custom_renderer` is the registry that routes filenames here.
12+
13+use maud::{
14+ Markup,
15+ html,
16+};
17+
18+use crate::ui::render_markdown;
19+
20+pub struct Section {
21+ /// Heading level (1–6); 0 for the implicit preamble section.
22+ pub level: u8,
23+ /// Heading text with any `[x]` / `✓` done-marker stripped.
24+ pub title: String,
25+ /// Done section: tasks in it count as done regardless of their checkbox.
26+ pub done: bool,
27+ pub tasks: Vec<Task>,
28+ /// Non-task content of the section, verbatim (used for the notes area).
29+ pub prose: String,
30+}
31+
32+pub struct Task {
33+ pub done: bool,
34+ pub title: String,
35+ /// Raw markdown block of nested/indented lines, dedented one level.
36+ pub details: String,
37+}
38+
39+/// Whether a path names a `TODO.md` (any directory, any case).
40+pub fn is_todo_md(path: &str) -> bool {
41+ std::path::Path::new(path)
42+ .file_name()
43+ .is_some_and(|n| n.eq_ignore_ascii_case("TODO.md"))
44+}
45+
46+/// Parse a todo-md document into sections. The preamble (content before the
47+/// first heading) becomes a level-0 section with an empty title.
48+pub fn parse(text: &str) -> Vec<Section> {
49+ let mut sections = vec![Section {
50+ level: 0,
51+ title: String::new(),
52+ done: false,
53+ tasks: Vec::new(),
54+ prose: String::new(),
55+ }];
56+ let mut in_fence = false;
57+ // Index into the current section's tasks while detail lines may still
58+ // attach; None once a non-indented line ends the task block.
59+ let mut open_task = false;
60+
61+ for line in text.lines() {
62+ let cur = sections.last_mut().expect("never empty");
63+ if line.trim_start().starts_with("```") {
64+ in_fence = !in_fence;
65+ }
66+ if in_fence || line.trim_start().starts_with("```") {
67+ // Fenced content is never structural.
68+ append_line(cur, open_task, line);
69+ continue;
70+ }
71+
72+ if let Some((level, rest)) = heading(line) {
73+ let done = rest.contains("[x]") || rest.contains("[X]") || rest.contains('✓');
74+ let title = rest
75+ .replace("[x]", "")
76+ .replace("[X]", "")
77+ .replace('✓', "")
78+ .trim()
79+ .to_string();
80+ sections.push(Section {
81+ level,
82+ title,
83+ done,
84+ tasks: Vec::new(),
85+ prose: String::new(),
86+ });
87+ open_task = false;
88+ continue;
89+ }
90+
91+ if let Some((done, title)) = task_line(line) {
92+ cur.tasks.push(Task {
93+ done,
94+ title: title.to_string(),
95+ details: String::new(),
96+ });
97+ open_task = true;
98+ continue;
99+ }
100+
101+ // Indented (or blank) lines under a task are its details; anything
102+ // else is section prose and closes the task block.
103+ if open_task && !line.is_empty() && !line.starts_with(" ") {
104+ open_task = false;
105+ }
106+ append_line(cur, open_task, line);
107+ }
108+
109+ for s in &mut sections {
110+ if s.done {
111+ for t in &mut s.tasks {
112+ t.done = true;
113+ }
114+ }
115+ for t in &mut s.tasks {
116+ // Drop trailing blank lines swallowed while the task was open.
117+ while t.details.ends_with('\n') {
118+ t.details.pop();
119+ }
120+ }
121+ }
122+ sections
123+}
124+
125+fn append_line(section: &mut Section, to_task: bool, line: &str) {
126+ let target = if to_task {
127+ let t = section.tasks.last_mut().expect("open task exists");
128+ &mut t.details
129+ } else {
130+ &mut section.prose
131+ };
132+ // Dedent detail lines one level (2–4 spaces) so they render as their own
133+ // markdown rather than a code block.
134+ let line = if to_task {
135+ let spaces = line.len() - line.trim_start_matches(' ').len();
136+ &line[spaces.min(4).min(line.len())..]
137+ } else {
138+ line
139+ };
140+ target.push_str(line);
141+ target.push('\n');
142+}
143+
144+fn heading(line: &str) -> Option<(u8, &str)> {
145+ let hashes = line.bytes().take_while(|&b| b == b'#').count();
146+ if (1..=6).contains(&hashes) && line[hashes..].starts_with(' ') {
147+ Some((hashes as u8, line[hashes..].trim()))
148+ } else {
149+ None
150+ }
151+}
152+
153+fn task_line(line: &str) -> Option<(bool, &str)> {
154+ let open = line.strip_prefix("- [ ] ");
155+ let done = line.strip_prefix("- [x] ").or(line.strip_prefix("- [X] "));
156+ match (open, done) {
157+ (Some(rest), _) => Some((false, rest.trim())),
158+ (_, Some(rest)) => Some((true, rest.trim())),
159+ _ => None,
160+ }
161+}
162+
163+/// Render a todo-md document as a kanban board (columns = task-bearing
164+/// sections) with prose sections as a notes area below. `None` if the file
165+/// contains no tasks at all — callers fall back to plain markdown.
166+pub fn render_board(text: &str) -> Option<Markup> {
167+ let sections = parse(text);
168+ if sections.iter().all(|s| s.tasks.is_empty()) {
169+ return None;
170+ }
171+
172+ // Prose-only sections (and stray preamble/column prose) become one
173+ // markdown notes blob, headings preserved.
174+ let mut notes = String::new();
175+ for s in &sections {
176+ let prose_empty = s.prose.trim().is_empty();
177+ if s.tasks.is_empty() && s.level > 0 && prose_empty {
178+ continue; // empty section: nothing to show either way
179+ }
180+ if s.tasks.is_empty() {
181+ if s.level > 0 {
182+ notes.push_str(&format!("{} {}\n", "#".repeat(s.level as usize), s.title));
183+ }
184+ if !prose_empty {
185+ notes.push_str(&s.prose);
186+ notes.push('\n');
187+ }
188+ }
189+ }
190+
191+ Some(html! {
192+ div.kanban {
193+ @for s in sections.iter().filter(|s| !s.tasks.is_empty()) {
194+ div.col {
195+ h3 {
196+ @if s.title.is_empty() { "Tasks" } @else { (s.title) }
197+ span.count {
198+ @if s.done {
199+ ({ s.tasks.len().to_string() }) " done"
200+ } @else {
201+ ({ s.tasks.iter().filter(|t| !t.done).count().to_string() })
202+ " open"
203+ }
204+ }
205+ }
206+ @for t in &s.tasks {
207+ div.card.done[t.done] {
208+ div.title {
209+ input type="checkbox" disabled checked[t.done];
210+ span { (render_markdown(&t.title)) }
211+ }
212+ @if !t.details.trim().is_empty() {
213+ details {
214+ summary { "details" }
215+ div.card-details { (render_markdown(&t.details)) }
216+ }
217+ }
218+ }
219+ }
220+ }
221+ }
222+ }
223+ @if !notes.trim().is_empty() {
224+ details.todo-notes {
225+ summary { "notes" }
226+ div.md-body { (render_markdown(&notes)) }
227+ }
228+ }
229+ })
230+}
231+
232+#[cfg(test)]
233+mod tests {
234+ use super::*;
235+
236+ const DOC: &str = "\
237+intro prose
238+
239+# Now
240+
241+- [ ] first task
242+ - a detail line
243+ - another `detail`
244+- [x] finished task
245+
246+# Done [x]
247+
248+- [ ] moved here, checkbox stale
249+
250+# Notes
251+
252+just prose, no tasks
253+
254+```
255+# not a heading
256+- [ ] not a task
257+```
258+";
259+
260+ #[test]
261+ fn parses_sections_tasks_details() {
262+ let s = parse(DOC);
263+ assert_eq!(s.len(), 4); // preamble + 3 headings
264+ assert_eq!(s[0].level, 0);
265+ assert_eq!(s[0].prose.trim(), "intro prose");
266+
267+ assert_eq!(s[1].title, "Now");
268+ assert!(!s[1].done);
269+ assert_eq!(s[1].tasks.len(), 2);
270+ assert_eq!(s[1].tasks[0].title, "first task");
271+ assert!(!s[1].tasks[0].done);
272+ assert_eq!(s[1].tasks[0].details, "- a detail line\n- another `detail`");
273+ assert!(s[1].tasks[1].done);
274+
275+ // Done section: heading marker wins over the task's own checkbox.
276+ assert_eq!(s[2].title, "Done");
277+ assert!(s[2].done);
278+ assert!(s[2].tasks[0].done);
279+
280+ // Fenced content is not structural.
281+ assert_eq!(s[3].title, "Notes");
282+ assert!(s[3].tasks.is_empty());
283+ assert!(s[3].prose.contains("# not a heading"));
284+ }
285+
286+ #[test]
287+ fn todo_md_by_filename() {
288+ assert!(is_todo_md("TODO.md"));
289+ assert!(is_todo_md("docs/todo.MD"));
290+ assert!(!is_todo_md("TODO.txt"));
291+ assert!(!is_todo_md("NOT-TODO.md"));
292+ }
293+
294+ #[test]
295+ fn board_renders_columns_and_falls_back() {
296+ let board = render_board(DOC).expect("has tasks").into_string();
297+ assert!(board.contains("kanban"));
298+ assert!(board.contains("Now"));
299+ assert!(board.contains("first task"));
300+ assert!(board.contains("just prose"), "notes area kept: {board}");
301+ assert!(render_board("# readme\n\nonly prose\n").is_none());
302+ }
303+}
modifiedcrates/anvil-web/src/ui.rs+61 −9
⋯ 77 unchanged lines
7878 };
7979 use time::OffsetDateTime;
8080
81-use crate::auth::{
82- CSRF_FIELD,
83- Csrf,
84- CurrentUser,
85- verify_csrf,
81+use crate::{
82+ auth::{
83+ CSRF_FIELD,
84+ Csrf,
85+ CurrentUser,
86+ verify_csrf,
87+ },
88+ todomd,
8689 };
8790
8891 const STYLE: &str = r#"
⋯ 75 unchanged lines
164167 .btn.btn-secondary { background:var(--bg); color:var(--fg); border-color:var(--border); }
165168 .readme { margin-top:16px; }
166169 .readme-head { padding:8px 16px; border-bottom:1px solid var(--border); background:var(--code-bg); font-size:13px; font-weight:600; }
170+.kanban { display:flex; gap:12px; align-items:flex-start; overflow-x:auto; padding:12px; }
171+.kanban .col { flex:1 1 0; min-width:230px; background:var(--code-bg); border:1px solid var(--border); border-radius:6px; padding:8px; }
172+.kanban .col h3 { margin:2px 4px 8px; font-size:13px; display:flex; align-items:baseline; gap:8px; }
173+.kanban .col h3 .count { font-weight:400; font-size:12px; color:var(--muted); margin-left:auto; }
174+.kanban .card { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:6px 10px; margin-bottom:8px; font-size:13px; }
175+.kanban .card:last-child { margin-bottom:0; }
176+.kanban .card .title { display:flex; gap:6px; align-items:baseline; }
177+.kanban .card .title p { margin:0; display:inline; }
178+.kanban .card.done .title { color:var(--muted); text-decoration:line-through; }
179+.kanban .card details { margin-top:4px; }
180+.kanban .card summary { cursor:pointer; font-size:12px; color:var(--muted); }
181+.kanban .card .card-details { padding:4px 0 0 6px; font-size:12px; }
182+.kanban .card .card-details p { margin:0 0 4px; }
183+.todo-notes { margin:0 12px 12px; }
184+.todo-notes > summary { cursor:pointer; font-size:13px; color:var(--muted); }
167185 .latest-commit { display:flex; gap:10px; align-items:baseline; background:var(--code-bg); border:1px solid var(--border); border-radius:6px 6px 0 0; border-bottom:0; padding:8px 16px; }
168186 .latest-commit + .box { border-radius:0 0 6px 6px; }
169187 .commit-list { list-style:none; padding:0; margin:0; }
⋯ 746 unchanged lines
916934 ))
917935 });
918936
937+ // A root TODO.md with tasks renders as a kanban board below the README.
938+ let todo_board = entries
939+ .iter()
940+ .find(|e| !e.is_dir && e.name.eq_ignore_ascii_case("todo.md"))
941+ .and_then(|e| {
942+ let bytes = browse::read_blob(&path, &rev, &e.name).ok().flatten()?;
943+ let board = todomd::render_board(&String::from_utf8_lossy(&bytes))?;
944+ Some((board, e.name.clone()))
945+ });
946+
919947 Ok(layout(
920948 &format!("{owner}/{repo}"),
921949 user.as_ref(),
⋯ 23 unchanged lines
945973 div.md-body { (rendered) }
946974 }
947975 }
976+ @if let Some((board, name)) = &todo_board {
977+ div.box.readme {
978+ div.readme-head {
979+ a href=(format!("/{owner}/{repo}/blob/{}/{name}", enc_ref(&rev))) { (name) }
980+ }
981+ (board)
982+ }
983+ }
948984 },
949985 ))
950986 }
⋯ 54 unchanged lines
10051041 .ok_or_else(|| not_found("file not found"))?;
10061042
10071043 let markdown = is_markdown(&path) && !is_binary(&bytes);
1008- let rendered = markdown && !query.contains_key("plain");
1044+ // Custom renderers for well-known filenames (the plugin point — add new
1045+ // filename → renderer pairs here). TODO.md defaults to a kanban board.
1046+ let is_todo = todomd::is_todo_md(&path) && !is_binary(&bytes);
1047+ let board = (is_todo && !query.contains_key("plain") && !query.contains_key("md"))
1048+ .then(|| todomd::render_board(&String::from_utf8_lossy(&bytes)))
1049+ .flatten();
1050+ let rendered = markdown && !query.contains_key("plain") && board.is_none();
10091051
1010- let body = if is_binary(&bytes) {
1052+ let body = if let Some(board) = &board {
1053+ board.clone()
1054+ } else if is_binary(&bytes) {
10111055 html! { p.muted { "Binary file (" (bytes.len()) " bytes)" } }
10121056 } else if rendered {
10131057 let text = String::from_utf8_lossy(&bytes);
⋯ 24 unchanged lines
10381082 @if markdown {
10391083 p.view-toggle {
10401084 span.pill-group {
1041- @if rendered {
1085+ @if is_todo {
1086+ @if board.is_some() { span.pill.active { "Board" } }
1087+ @else { a.pill href=(&blob_url) { "Board" } }
1088+ @if rendered { span.pill.active { "Rendered" } }
1089+ @else { a.pill href=(format!("{blob_url}?md=1")) { "Rendered" } }
1090+ } @else if rendered {
10421091 span.pill.active { "Rendered" }
1092+ } @else {
1093+ a.pill href=(&blob_url) { "Rendered" }
1094+ }
1095+ @if rendered || board.is_some() {
10431096 a.pill href=(format!("{blob_url}?plain=1")) { "Source" }
10441097 } @else {
1045- a.pill href=(blob_url) { "Rendered" }
10461098 span.pill.active { "Source" }
10471099 }
10481100 }
⋯ 746 unchanged lines