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"); } }); }