diff --git a/README.md b/README.md index a506f19..520c19e 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ imports here; see [Bases and Canvas](#bases-and-canvas) below. | Tables | ✅ | ✅ | ✅ | | Strikethrough `~~text~~` | ✅ | ✅ | ✅ | | Task lists `- [ ]` | ✅ | ✅ | ✅ | -| Alternate task states `- [/]` | — | ✅ | — | +| Alternate task states `- [/]` | ✅ | ✅ | — | | Footnotes `[^1]` | ✅ | ✅ | ✅ | | Inline footnotes `^[text]` | ✅ | ✅ | — | | Highlight `==text==` | ✅ | ✅ | — | diff --git a/public/style.css b/public/style.css index 118acd2..6ab9f05 100644 --- a/public/style.css +++ b/public/style.css @@ -177,6 +177,27 @@ details[open] > summary .callout-fold { vertical-align: middle; } +/* Alternate task states (`- [/]`, `- [?]`, `- [>]`). Obsidian ships an icon per state and + leaves the rest to a theme; with no task theme here the marker is drawn in a box the + size of a checkbox, so the state stays legible. `data-task` is the hook either way. */ +.task-alt input[type="checkbox"] { + display: none; +} + +.task-alt::before { + content: attr(data-task); + display: inline-block; + width: 1em; + height: 1em; + margin-right: 0.5em; + border: 1px solid var(--border); + border-radius: 3px; + font-size: 0.85em; + line-height: 1em; + text-align: center; + vertical-align: middle; +} + /* ==highlight== markup */ mark { background-color: var(--accent-soft-border); diff --git a/src/lib/markdown/base-plugins.ts b/src/lib/markdown/base-plugins.ts index fd306cb..565ec77 100644 --- a/src/lib/markdown/base-plugins.ts +++ b/src/lib/markdown/base-plugins.ts @@ -11,6 +11,7 @@ import { headingAnchorPlugin } from "./heading-anchor-plugin.ts"; import { imagePlugin } from "./image-plugin.ts"; import { katexPlugin } from "./katex-plugin.ts"; import { tableWrapPlugin } from "./table-wrap-plugin.ts"; +import { taskStatePlugin } from "./task-state-plugin.ts"; import { wikilinkPlugin } from "./wikilink-plugin.ts"; // shiki/viz are deliberately server-only (heavy WASM grammars / separate D3 bundle), so they live in markdown.ts, not here. @@ -23,6 +24,7 @@ export function useBaseMarkdownPlugins(md: MarkdownIt): void { md.use(footnotePlugin); md.use(markPlugin); md.use(taskListPlugin, { enabled: false, label: true }); + md.use(taskStatePlugin); md.use(imagePlugin); md.use(tableWrapPlugin); md.use(calloutPlugin); diff --git a/src/lib/markdown/sanitize.ts b/src/lib/markdown/sanitize.ts index 0c8fce5..1a4c272 100644 --- a/src/lib/markdown/sanitize.ts +++ b/src/lib/markdown/sanitize.ts @@ -102,6 +102,7 @@ const SANITIZE_OPTIONS: sanitizeHtml.IOptions = { "style", "data-viz-type", "data-viz", + "data-task", "aria-hidden", "tabindex", ], diff --git a/src/lib/markdown/task-state-plugin.ts b/src/lib/markdown/task-state-plugin.ts new file mode 100644 index 0000000..26101fa --- /dev/null +++ b/src/lib/markdown/task-state-plugin.ts @@ -0,0 +1,90 @@ +import type { MarkdownIt, StateCore, Token } from "markdown-it"; + +const TASK_RE = /^\[([^\s\]])\] /; + +function isCheckbox(token: Token): boolean { + return ( + token.type === "html_inline" && + token.content.includes("task-list-item-checkbox") + ); +} + +function htmlInline(state: StateCore, content: string): Token { + const token = new state.Token("html_inline", "", 0); + token.content = content; + return token; +} + +function checkboxHtml( + escapeHtml: (value: string) => string, + task: string, +): string { + const checked = task === "x" ? ' checked=""' : ""; + return ``; +} + +function addClass(token: Token | undefined, name: string): void { + if (!token) return; + const current = String(token.attrGet("class") ?? ""); + if (!current.split(" ").includes(name)) token.attrJoin("class", name); +} + +// The list the item belongs to, found the way markdown-it-task-lists finds it. +function parentToken(tokens: Token[], index: number): Token | undefined { + const level = (tokens[index]?.level ?? 0) - 1; + for (let i = index - 1; i >= 0; i--) { + if (tokens[i]?.level === level) return tokens[i]; + } + return undefined; +} + +// Obsidian renders any single character between the brackets as a checkbox and exposes it +// as `data-task` — the hook themes style `- [/]`, `- [?]` and `- [>]` from. markdown-it-task-lists +// knows only space and `x`, so alternate states get their checkbox here and the two it +// already handled get the same attribute. The attribute sits on the `li` too, which is what +// Obsidian themes select on and what public/style.css draws the marker from. +// +// Alternate states render *unchecked*: every convention using them (`/` in progress, +// `?` question, `>` forwarded, `-` cancelled) means not done, and a ticked box would say +// the opposite to a reader with no theme installed. +export function taskStatePlugin(mdi: MarkdownIt): void { + const escapeHtml = mdi.utils.escapeHtml; + + mdi.core.ruler.after("github-task-lists", "task_states", (state) => { + const tokens = state.tokens; + for (let i = 2; i < tokens.length; i++) { + const inline = tokens[i]; + const item = tokens[i - 2]; + if (inline?.type !== "inline") continue; + if (tokens[i - 1]?.type !== "paragraph_open") continue; + if (item?.type !== "list_item_open") continue; + + const existing = inline.children?.find(isCheckbox); + if (existing) { + const task = existing.content.includes('checked=""') ? "x" : " "; + existing.content = checkboxHtml(escapeHtml, task); + item.attrSet("data-task", task); + continue; + } + + const match = TASK_RE.exec(inline.content); + const first = inline.children?.[0]; + if (!match || first?.type !== "text") continue; + + // slice(3) keeps the space before the label text, as the sibling plugin does. + const task = match[1] as string; + first.content = first.content.slice(3); + inline.content = inline.content.slice(3); + inline.children?.unshift( + htmlInline(state, "")); + + addClass(item, "task-list-item"); + addClass(item, "task-alt"); + item.attrSet("data-task", task); + addClass(parentToken(tokens, i - 2), "contains-task-list"); + } + }); +} diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts index 5a62fb5..480ddf3 100644 --- a/tests/lib/markdown.test.ts +++ b/tests/lib/markdown.test.ts @@ -463,6 +463,49 @@ describe("task lists", () => { expect(html).toContain('type="checkbox"'); expect(html).toContain("Regular item"); }); + + test("exposes the state of a plain task as data-task", () => { + expect(renderMarkdown("- [ ] Todo").html).toContain('data-task=" "'); + expect(renderMarkdown("- [x] Done").html).toContain('data-task="x"'); + }); + + test("renders an alternate state as a checkbox", () => { + const { html } = renderMarkdown("- [/] Partially done"); + expect(html).toContain('type="checkbox"'); + expect(html).toContain('data-task="/"'); + expect(html).toContain("task-alt"); + expect(html).toContain("Partially done"); + expect(html).not.toContain("[/]"); + }); + + test("leaves an alternate state unchecked", () => { + const { html } = renderMarkdown("- [?] Unsure"); + expect(html).not.toContain("checked"); + }); + + test("marks a mixed list once, not once per task", () => { + const { html } = renderMarkdown("- [ ] plain\n- [/] alt\n- [>] other"); + expect(html.match(/contains-task-list/g)).toHaveLength(1); + }); + + test("does not checkbox a list item opening with a link", () => { + const { html } = renderMarkdown("- [/](https://example.com) not a task"); + expect(html).not.toContain('type="checkbox"'); + expect(html).toContain(" { + const { html } = renderMarkdown('- ["] Quoted marker'); + expect(html).toContain( + 'class="task-list-item-checkbox" data-task="""', + ); + }); + + test("does not checkbox a paragraph outside a list", () => { + const { html } = renderMarkdown("> [/] quoted, not a task"); + expect(html).not.toContain('type="checkbox"'); + expect(html).toContain("[/]"); + }); }); describe("footnotes", () => {