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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/11869-a-markdown-page-draws-task-lists-as-read → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/11869-a-markdown-page-draws-task-lists-as-read was merged into main as a16b564.

Review

Sign in to review this change.
4 files changed +84 −7
src/web/markdown.ts +23 −2
@@ -202,6 +202,13 @@ export function markdownMemoStats(): { hits: number; misses: number } {
202202 return { ...counts };
203203 }
204204
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
205212 export function renderMarkdown(source: string, repo?: MarkdownRepo): Html {
206213 if (source.length > MEMO_MAX_SOURCE) return renderUncached(source, repo);
207214 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 {
241248 let align: string[] | null = null;
242249 let skip = -1;
243250 let folded = false;
251 const tasks = { done: 0, total: 0 };
244252
245253 const closeParagraph = () => {
246254 if (paragraph.length > 0) {
@@ -351,7 +359,18 @@ function renderUncached(source: string, repo?: MarkdownRepo): Html {
351359 out.push(`<${kind}>`);
352360 list = kind;
353361 }
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>`,
outdated

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.

370 );
371 } else {
372 out.push(`<li>${inline(item, repo)}</li>`);
373 }
355374 continue;
356375 }
357376
@@ -379,5 +398,7 @@ function renderUncached(source: string, repo?: MarkdownRepo): Html {
379398 if (fence) out.push(closeFence(body, fenceLang));
380399 closeBlocks();
381400 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;
383404 }
src/web/render/markdown.ts +12 −3
@@ -1,7 +1,8 @@
11 /** A Markdown file as prose, framed exactly the way a README is. */
22
33 import { html } from "../html.ts";
4 import { renderMarkdown } from "../markdown.ts";
4 import { markdownTasks, renderMarkdown } from "../markdown.ts";
5 import { fileSize } from "../ui.ts";
56 import type { Rendered, RenderInput } from "./renderers.ts";
67
78 export function markdown(input: RenderInput): Rendered {
@@ -11,8 +12,16 @@ export function markdown(input: RenderInput): Rendered {
1112 ? undefined
1213 : { ...input.repo, dir: slash < 0 ? "" : input.path.slice(0, slash) };
1314 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);
1418 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 }),
1726 };
1827 }
src/web/style/content.ts +5 −2
@@ -558,8 +558,11 @@ form.inline { display: inline; margin-left: auto; }
558558 .prose h1:first-child, .prose h2:first-child, .prose h3:first-child { margin-top: 0; }
559559 .prose h1, .prose h2 { padding-bottom: 6px; border-bottom: 1px solid var(--border); }
560560 .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; }
563566 .prose li::marker { color: var(--faint); }
564567 .prose pre {
565568 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("&lt;script&gt;");
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs