import crypto from 'crypto'; import MarkdownIt from 'markdown-it'; import Token from 'markdown-it/lib/token.js'; import StateCore from 'markdown-it/lib/rules_core/state_core'; interface RadioOptions { enabled?: boolean; label?: boolean; } /** * Modified from https://github.com/revin/markdown-it-task-lists/blob/master/index.js */ export function radioButtonPlugin(md: MarkdownIt, options?: RadioOptions): void { const disableRadio = options ? !options.enabled : false; const useLabelWrapper = options ? !!options.label : true; md.core.ruler.after('inline', 'radio-lists', (state: StateCore) => { const { tokens } = state; for (let i = 2; i < tokens.length; i++) { if (isTodoItem(tokens, i)) { const parentIdx = parentToken(tokens, i - 2); if (parentIdx === -1) continue; const parent = tokens[parentIdx]; const groupAttr = attrGet(parent, 'radio-group'); // try retrieve the group id let group: string; if (groupAttr) { group = groupAttr[1]; } else { const hash = crypto.createHash('md5'); if (i >= 5 && tokens[i - 5]) { hash.update(tokens[i - 5].content); } if (i >= 4 && tokens[i - 4]) { hash.update(tokens[i - 4].content); } group = hash.update(tokens[i].content).digest('hex').slice(2, 7); // generate a deterministic group id } radioify(tokens[i], group, disableRadio, useLabelWrapper); attrSet(tokens[i - 2], 'class', 'radio-list-item'); attrSet(parent, 'radio-group', group); // save the group id to the top-level list attrSet(parent, 'class', 'radio-list'); } } }); } function attrSet(token: Token, name: string, value: string): void { const index = token.attrIndex(name); const attr: [string, string] = [name, value]; if (index < 0) { token.attrPush(attr); } else if (token.attrs) { token.attrs[index] = attr; } } function attrGet(token: Token, name: string): [string, string] | undefined { const index = token.attrIndex(name); if (index < 0 || !token.attrs) { return undefined; } return token.attrs[index] as [string, string]; } function parentToken(tokens: Token[], index: number): number { const targetLevel = tokens[index].level - 1; for (let i = index - 1; i >= 0; i--) { if (tokens[i].level === targetLevel) { return i; } } return -1; } function isTodoItem(tokens: Token[], index: number): boolean { return isInline(tokens[index]) && isParagraph(tokens[index - 1]) && isListItem(tokens[index - 2]) && startsWithTodoMarkdown(tokens[index]); } function radioify(token: Token, radioId: string, disableRadio: boolean, useLabelWrapper: boolean): void { if (!token.children) token.children = []; token.children.unshift(makeRadioButton(token, radioId, disableRadio)); token.children[1].content = token.children[1].content.slice(3); token.content = token.content.slice(3); if (useLabelWrapper) { // Removed beingLabel & endLabel functions since we can just use new Token(...) now. token.children.unshift(new Token('html_inline', '', 0)); token.children[0].content = ''; } } function makeRadioButton(token: Token, radioId: string, disableRadio: boolean): Token { const radio = new Token('html_inline', '', 0); const disabledAttr = disableRadio ? ' disabled="" ' : ''; const isUnchecked = token.content.indexOf('( ) ') === 0; const isChecked = token.content.indexOf('(x) ') === 0 || token.content.indexOf('(X) ') === 0; if (isUnchecked) { radio.content = ``; } else if (isChecked) { radio.content = ``; } return radio; } function isInline(token: Token): boolean { return token.type === 'inline'; } function isParagraph(token: Token): boolean { return token.type === 'paragraph_open'; } function isListItem(token: Token): boolean { return token.type === 'list_item_open'; } function startsWithTodoMarkdown(token: Token): boolean { // leading whitespace in a list item is already trimmed off by markdown-it return token.content.indexOf('( ) ') === 0 || token.content.indexOf('(x) ') === 0 || token.content.indexOf('(X) ') === 0; }