Public: anyone can read this; the crew can change it.
A markdown page draws task lists as read-only checkboxes and counts them in its heading #11895
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11869-a-markdown-page-draws-task-lists-as-read → main 3 commits fork 2 threads · 2 unresolved
4 files
changed +84
−7
src/web/markdown.ts +23 −2
| @@ -202,6 +202,13 @@ export function markdownMemoStats(): { hits: number; misses: number } { | ||
| 202 | 202 | return { ...counts }; |
| 203 | 203 | } |
| 204 | 204 | |
| 205 | const taskCounts = new WeakMap<Html, { done: number; total: number }>(); | |
| 206 | ||
| 207 | /** The `- [ ]` / `- [x]` items a rendering drew; `undefined` when it drew none. */ | |
| 208 | export function markdownTasks(rendered: Html): { done: number; total: number } | undefined { | |
| 209 | return taskCounts.get(rendered); | |
| 210 | } | |
| 211 | ||
| 205 | 212 | export function renderMarkdown(source: string, repo?: MarkdownRepo): Html { |
| 206 | 213 | if (source.length > MEMO_MAX_SOURCE) return renderUncached(source, repo); |
| 207 | 214 | const key = repo === undefined ? source : `${repo.base}\0${repo.ref}\0${repo.dir}\0\0${source}`; |
| @@ -241,6 +248,7 @@ function renderUncached(source: string, repo?: MarkdownRepo): Html { | ||
| 241 | 248 | let align: string[] | null = null; |
| 242 | 249 | let skip = -1; |
| 243 | 250 | let folded = false; |
| 251 | const tasks = { done: 0, total: 0 }; | |
| 244 | 252 | |
| 245 | 253 | const closeParagraph = () => { |
| 246 | 254 | if (paragraph.length > 0) { |
| @@ -351,7 +359,18 @@ function renderUncached(source: string, repo?: MarkdownRepo): Html { | ||
| 351 | 359 | out.push(`<${kind}>`); |
| 352 | 360 | list = kind; |
| 353 | 361 | } |
| 354 | out.push(`<li>${inline((bullet ?? numbered)![1]!, repo)}</li>`); | |
| 362 | const item = (bullet ?? numbered)![1]!; | |
| 363 | const task = bullet ? /^\[([ xX])\] +(\S.*)$/.exec(item) : null; | |
| 364 | if (task !== null) { | |
| 365 | const ticked = task[1] !== " "; | |
| 366 | tasks.total++; | |
| 367 | if (ticked) tasks.done++; | |
| 368 | out.push( | |
| 369 | `<li class="task"><label><input type="checkbox" disabled${ticked ? " checked" : ""}> ${inline(task[2]!, repo)}</label></li>`, | |
| 370 | ); | |
| 371 | } else { | |
| 372 | out.push(`<li>${inline(item, repo)}</li>`); | |
| 373 | } | |
| 355 | 374 | continue; |
| 356 | 375 | } |
| 357 | 376 | |
| @@ -379,5 +398,7 @@ function renderUncached(source: string, repo?: MarkdownRepo): Html { | ||
| 379 | 398 | if (fence) out.push(closeFence(body, fenceLang)); |
| 380 | 399 | closeBlocks(); |
| 381 | 400 | if (folded) out.push("</details>"); |
| 382 | return raw(out.join("\n")); | |
| 401 | const made = raw(out.join("\n")); | |
| 402 | if (tasks.total > 0) taskCounts.set(made, tasks); | |
| 403 | return made; | |
| 383 | 404 | } |
src/web/render/markdown.ts +12 −3
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | /** A Markdown file as prose, framed exactly the way a README is. */ |
| 2 | 2 | |
| 3 | 3 | import { html } from "../html.ts"; |
| 4 | import { renderMarkdown } from "../markdown.ts"; | |
| 4 | import { markdownTasks, renderMarkdown } from "../markdown.ts"; | |
| 5 | import { fileSize } from "../ui.ts"; | |
| 5 | 6 | import type { Rendered, RenderInput } from "./renderers.ts"; |
| 6 | 7 | |
| 7 | 8 | export function markdown(input: RenderInput): Rendered { |
| @@ -11,8 +12,16 @@ export function markdown(input: RenderInput): Rendered { | ||
| 11 | 12 | ? undefined |
| 12 | 13 | : { ...input.repo, dir: slash < 0 ? "" : input.path.slice(0, slash) }; |
| 13 | 14 | const text = new TextDecoder().decode(input.body); |
| 15 | const lines = text === "" ? 0 : text.replace(/\n$/, "").split("\n").length; | |
| 16 | const body = renderMarkdown(text, repo); | |
| 17 | const tasks = markdownTasks(body); | |
| 14 | 18 | return { |
| 15 | lines: text === "" ? 0 : text.replace(/\n$/, "").split("\n").length, | |
| 16 | content: html`<div class="card prose">${renderMarkdown(text, repo)}</div>`, | |
| 19 | lines, | |
| 20 | content: html`<div class="card prose">${body}</div>`, | |
| 21 | ...(tasks === undefined | |
| 22 | ? {} | |
| 23 | : { | |
| 24 | summary: `${lines.toLocaleString()} lines · ${tasks.done} of ${tasks.total} done · ${fileSize(input.size)}`, | |
| 25 | }), | |
| 17 | 26 | }; |
| 18 | 27 | } |
src/web/style/content.ts +5 −2
| @@ -558,8 +558,11 @@ form.inline { display: inline; margin-left: auto; } | ||
| 558 | 558 | .prose h1:first-child, .prose h2:first-child, .prose h3:first-child { margin-top: 0; } |
| 559 | 559 | .prose h1, .prose h2 { padding-bottom: 6px; border-bottom: 1px solid var(--border); } |
| 560 | 560 | .prose p, .prose ul, .prose ol, .prose blockquote { margin: 12px 0; text-wrap: pretty; } |
| 561 | .prose ul { list-style: disc; padding-left: 24px; } | |
| 562 | .prose ol { list-style: decimal; padding-left: 24px; } | |
| 561 | .prose { --list-indent: 24px; } | |
| 562 | .prose ul { list-style: disc; padding-left: var(--list-indent); } | |
| 563 | .prose ol { list-style: decimal; padding-left: var(--list-indent); } | |
outdated
Hand-rolled -20px offset depends on the 24px padding two lines above; use one shared value so the checkbox lines up if the padding changes (principle 3, rhythm). |
||
| 564 | .prose li.task { list-style: none; margin-left: calc(-1 * var(--list-indent)); } | |
| 565 | .prose li.task input { margin: 0 6px 0 0; vertical-align: -1px; } | |
| 563 | 566 | .prose li::marker { color: var(--faint); } |
| 564 | 567 | .prose pre { |
| 565 | 568 | background: var(--muted); border: 1px solid var(--border); |
test/render-markdown-tasks.test.ts added +44
| @@ -0,0 +1,44 @@ | ||
| 1 | import { describe, expect, test } from "bun:test"; | |
| 2 | import { renderMarkdown } from "../src/web/markdown.ts"; | |
| 3 | import { markdown } from "../src/web/render/markdown.ts"; | |
| 4 | ||
| 5 | const md = (s: string): string => String(renderMarkdown(s)); | |
| 6 | const page = (s: string) => { | |
| 7 | const body = new TextEncoder().encode(s); | |
| 8 | return markdown({ path: "PLAN.md", body, size: body.length } as never); | |
| 9 | }; | |
| 10 | ||
| 11 | describe("task lists", () => { | |
| 12 | test("ticked and unticked items are disabled checkboxes", () => { | |
| 13 | const out = md("- [ ] write\n- [x] ship\n- [X] rest"); | |
| 14 | expect(out).toContain('<li class="task"><label><input type="checkbox" disabled> write</label></li>'); | |
| 15 | expect(out).toContain('<li class="task"><label><input type="checkbox" disabled checked> ship</label></li>'); | |
| 16 | expect(out.match(/disabled checked/g)?.length).toBe(2); | |
| 17 | expect(out).not.toContain("<form"); | |
| 18 | }); | |
| 19 | test("every checkbox sits inside a label with its text, so it has an accessible name", () => { | |
| 20 | const out = md("- [ ] write\n- [x] ship"); | |
| 21 | expect(out.match(/<label><input type="checkbox"[^>]*> [a-z]+<\/label>/g)?.length).toBe(2); | |
| 22 | expect(out.match(/<input/g)?.length).toBe(out.match(/<label>/g)?.length); | |
| 23 | }); | |
| 24 | test("nested items and brackets in prose are left alone", () => { | |
| 25 | const out = md("- [ ] top\n - [ ] nested\n\n- plain [ ] x"); | |
| 26 | expect(out.match(/<input/g)?.length).toBe(1); | |
| 27 | expect(out).toContain("<li>plain [ ] x</li>"); | |
| 28 | }); | |
| 29 | test("a code fence and inline code are untouched", () => { | |
| 30 | const out = md("```\n- [ ] no\n```\n\n- `[ ] code`"); | |
| 31 | expect(out).not.toContain("<input"); | |
| 32 | expect(out).toContain("- [ ] no"); | |
| 33 | }); | |
| 34 | test("item text is escaped", () => { | |
| 35 | const out = md("- [x] <script>alert(1)</script>"); | |
| 36 | expect(out).not.toContain("<script"); | |
| 37 | expect(out).toContain("<script>"); | |
| 38 | }); | |
| 39 | test("the page names progress only when there is a task", () => { | |
| 40 | expect(page("- [x] a\n- [ ] b\n- [ ] c\n").summary).toContain("1 of 3 done"); | |
| 41 | expect(page("- a\n- b\n").summary).toBeUndefined(); | |
| 42 | expect(page("```\n- [ ] a\n```\n").summary).toBeUndefined(); | |
| 43 | }); | |
| 44 | }); |
new above
offline
Keyboard and screen-reader users get an unlabelled checkbox: the text sits beside the input, not in a label. Emit
<li class="task"><label><input type="checkbox" disabled checked> text</label></li>and update the test to assert the label.