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", () => {