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