diff --git a/bun.lock b/bun.lock index 0a5a1ad..26a943b 100644 --- a/bun.lock +++ b/bun.lock @@ -30,6 +30,7 @@ "markdown-it": "^14.1.1", "markdown-it-footnote": "^4.0.0", "markdown-it-mark": "^4.0.0", + "markdown-it-task-lists": "^2.1.1", "sanitize-html": "^2.17.4", "sharp": "^0.34.5", }, @@ -592,6 +593,8 @@ "markdown-it-mark": ["markdown-it-mark@4.0.0", "", {}, "sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg=="], + "markdown-it-task-lists": ["markdown-it-task-lists@2.1.1", "", {}, "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="], + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], diff --git a/package.json b/package.json index 9a296e0..dd8c31c 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "markdown-it": "^14.1.1", "markdown-it-footnote": "^4.0.0", "markdown-it-mark": "^4.0.0", + "markdown-it-task-lists": "^2.1.1", "sanitize-html": "^2.17.4", "sharp": "^0.34.5" }, diff --git a/public/editor/preview.ts b/public/editor/preview.ts index d9ccbe4..d3dd543 100644 --- a/public/editor/preview.ts +++ b/public/editor/preview.ts @@ -2,6 +2,7 @@ import DOMPurify from "dompurify"; import MarkdownIt from "markdown-it"; import footnotePlugin from "markdown-it-footnote"; import markPlugin from "markdown-it-mark"; +import taskListPlugin from "markdown-it-task-lists"; import { commentPlugin } from "../../src/lib/markdown/comment-plugin.ts"; import { katexPlugin } from "../../src/lib/markdown/katex-plugin.ts"; import { wikilinkPlugin } from "../../src/lib/markdown/wikilink-plugin.ts"; @@ -11,6 +12,7 @@ md.use(commentPlugin); md.use(wikilinkPlugin); md.use(footnotePlugin); md.use(markPlugin); +md.use(taskListPlugin, { enabled: false, label: true }); md.use(katexPlugin, { throwOnError: false }); // Matches the server-side allowlist closely. The preview is for the author's diff --git a/public/style.css b/public/style.css index 3cde34a..fe74ab7 100644 --- a/public/style.css +++ b/public/style.css @@ -29,6 +29,17 @@ margin-left: 0.25rem; } +/* Task lists */ +.task-list-item { + list-style-type: none; + margin-left: -1.5em; +} + +.task-list-item input[type="checkbox"] { + margin-right: 0.5em; + vertical-align: middle; +} + /* ==highlight== markup */ mark { background-color: var(--accent-soft); diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 5520e23..60e6e20 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,6 +1,7 @@ import MarkdownIt from "markdown-it"; import footnotePlugin from "markdown-it-footnote"; import markPlugin from "markdown-it-mark"; +import taskListPlugin from "markdown-it-task-lists"; import { commentPlugin } from "./markdown/comment-plugin.ts"; import { headingAnchorPlugin } from "./markdown/heading-anchor-plugin.ts"; import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts"; @@ -65,6 +66,7 @@ md.use(wikilinkPlugin); md.use(headingAnchorPlugin); md.use(footnotePlugin); md.use(markPlugin); +md.use(taskListPlugin, { enabled: false, label: true }); md.use(vizPlugin); md.use(katexPlugin, { throwOnError: false }); md.use(youtubePlugin); diff --git a/src/lib/markdown/sanitize.ts b/src/lib/markdown/sanitize.ts index bbc0ded..c607100 100644 --- a/src/lib/markdown/sanitize.ts +++ b/src/lib/markdown/sanitize.ts @@ -52,6 +52,8 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { "div", "span", "section", + "label", + "input", "iframe", // katex emits these for math rendering "math", @@ -95,6 +97,7 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { "class", "frameborder", ], + input: ["type", "checked", "disabled"], "*": ["id", "class", "style", "data-viz-type", "data-viz", "aria-hidden"], th: ["scope", "colspan", "rowspan", "align"], td: ["colspan", "rowspan", "align"], diff --git a/src/types.d.ts b/src/types.d.ts index 4420e8c..3520bbd 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -11,3 +11,14 @@ declare module "markdown-it-footnote" { const plugin: MarkdownIt.PluginSimple; export default plugin; } + +declare module "markdown-it-task-lists" { + import type MarkdownIt from "markdown-it"; + + const plugin: MarkdownIt.PluginWithOptions<{ + enabled?: boolean; + label?: boolean; + labelAfter?: boolean; + }>; + export default plugin; +} diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts index 5e77e40..f4d9a26 100644 --- a/tests/lib/markdown.test.ts +++ b/tests/lib/markdown.test.ts @@ -227,6 +227,32 @@ describe("highlights", () => { }); }); +describe("task lists", () => { + test("renders unchecked task", () => { + const { html } = renderMarkdown("- [ ] Todo item"); + expect(html).toContain('type="checkbox"'); + expect(html).not.toContain("checked"); + }); + + test("renders checked task", () => { + const { html } = renderMarkdown("- [x] Done item"); + expect(html).toContain('type="checkbox"'); + expect(html).toContain("checked"); + }); + + test("checkboxes are disabled (read-only)", () => { + const { html } = renderMarkdown("- [x] Item"); + expect(html).toContain("disabled"); + }); + + test("mixes regular list items and tasks", () => { + const src = "- Regular item\n- [ ] Task item\n- Another regular"; + const { html } = renderMarkdown(src); + expect(html).toContain('type="checkbox"'); + expect(html).toContain("Regular item"); + }); +}); + describe("footnotes", () => { test("renders reference + definition", () => { const { html } = renderMarkdown("Sentence.[^1]\n\n[^1]: Note text.");