UNPKG

@diplodoc/transform

Version:

A simple transformer of text in YFM (Yandex Flavored Markdown) to HTML

219 lines (187 loc) 6.86 kB
import type MarkdownIt from 'markdown-it'; import type Core from 'markdown-it/lib/parser_core'; import type StateCore from 'markdown-it/lib/rules_core/state_core'; import type Token from 'markdown-it/lib/token'; // eslint-disable-next-line no-useless-escape export const pattern = /^\[(X|\s|\_|\-)\]\s(.*)/i; export const CheckboxTokenType = { Checkbox: 'checkbox', CheckboxOpen: 'checkbox_open', CheckboxClose: 'checkbox_close', CheckboxInput: 'checkbox_input', CheckboxLabel: 'checkbox_label', CheckboxLabelOpen: 'checkbox_label_open', CheckboxLabelClose: 'checkbox_label_close', } as const; type ParsedCheckbox = { tokens: Token[]; content: string; startLine: number; endLine: number; checked: boolean; }; type ContentLine = { tokens: Token[]; content: string; }; function matchOpenToken(tokens: Token[], i: number) { if (tokens[i].type !== 'paragraph_open' || tokens[i + 1].type !== 'inline') { return false; } const firstInline = tokens[i + 1].children?.[0]; return firstInline?.type === 'text' && pattern.test(firstInline.content); } export type CheckboxOptions = { idPrefix?: string; divClass?: string; /** @default true */ disabled?: boolean; }; export const checkboxReplace = function (_md: MarkdownIt, opts?: CheckboxOptions): Core.RuleCore { let lastId = 0; const defaults: Required<CheckboxOptions> = { divClass: 'checkbox', idPrefix: 'checkbox', disabled: true, }; const options = Object.assign(defaults, opts); const createTokens = function (state: StateCore, checkbox: ParsedCheckbox): Token[] { let token: Token; const nodes: Token[] = []; /** * <div class="checkbox"> */ token = new state.Token(CheckboxTokenType.CheckboxOpen, 'div', 1); token.block = true; token.map = [checkbox.startLine, checkbox.endLine]; token.attrs = [['class', options.divClass]]; nodes.push(token); /** * <input type="checkbox" id="checkbox{n}" checked="true"> */ const id = options.idPrefix + lastId; lastId += 1; token = new state.Token(CheckboxTokenType.CheckboxInput, 'input', 0); token.block = true; token.map = [checkbox.startLine, checkbox.endLine]; token.attrs = [ ['type', 'checkbox'], ['id', id], ]; if (options.disabled) { token.attrSet('disabled', ''); } if (checkbox.checked === true) { token.attrSet('checked', 'true'); } nodes.push(token); /** * <label for="checkbox{n}"> */ token = new state.Token(CheckboxTokenType.CheckboxLabelOpen, 'label', 1); token.attrs = [['for', id]]; nodes.push(token); /** * content of label tag */ token = new state.Token('inline', '', 0); token.content = checkbox.content; token.children = checkbox.tokens; token.map = [checkbox.startLine, checkbox.endLine]; nodes.push(token); /** * closing tags */ token = new state.Token(CheckboxTokenType.CheckboxLabelClose, 'label', -1); token.block = true; token.map = [checkbox.startLine, checkbox.endLine]; nodes.push(token); token = new state.Token(CheckboxTokenType.CheckboxClose, 'div', -1); token.block = true; token.map = [checkbox.startLine, checkbox.endLine]; nodes.push(token); return nodes; }; return function (state) { const blockTokens = state.tokens; for (let i = 0; i < blockTokens.length; i++) { if (!matchOpenToken(blockTokens, i)) { continue; } const pToken = blockTokens[i]; const startLine = pToken.map?.[0] ?? NaN; const checkboxes = parseInlineContent(blockTokens[i + 1], startLine); const checkboxTokens: Token[] = []; for (const checkbox of checkboxes) { const first = checkbox.tokens[0]; // remove checkbox markup [X]␣ at start of text content first.content = first.content.slice(4); checkbox.content = checkbox.content.trim().slice(4); checkboxTokens.push(...createTokens(state, checkbox)); } // replace paragraph tokens with checkbox tokens if (checkboxTokens.length > 0) { blockTokens.splice(i, 3, ...checkboxTokens); i += checkboxTokens.length - 1; } } }; }; function parseInlineContent(inlineToken: Token, startLine: number): ParsedCheckbox[] { const lines = splitInlineTokensByBreaks(inlineToken); return parseCheckboxesByLines(lines, startLine); } function splitInlineTokensByBreaks(inlineToken: Token): ContentLine[] { const tokens = inlineToken.children || []; const contentLines = inlineToken.content.split('\n'); const lines: ContentLine[] = []; let lineIdx = 0; for (const token of tokens) { lines[lineIdx] ||= { tokens: [], content: contentLines[lineIdx], }; lines[lineIdx].tokens.push(token); if (isBreakToken(token)) { lineIdx += 1; } } return lines; } function parseCheckboxesByLines(lines: ContentLine[], startLine: number): ParsedCheckbox[] { const checkboxes: ParsedCheckbox[] = []; let checkboxIdx = -1; for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { const line = lines[lineIdx]; const first = line?.tokens[0]; if (!first) { continue; } let match; if (first.type === 'text' && (match = first.content.match(pattern))) { const prevLastToken = checkboxes[checkboxIdx]?.tokens.at(-1); if (prevLastToken && isBreakToken(prevLastToken)) { // remove hanging line breaks checkboxes[checkboxIdx].tokens.splice(-1); } checkboxIdx += 1; checkboxes[checkboxIdx] ||= { tokens: [], content: '', checked: isChecked(match[1]), startLine: startLine + lineIdx, endLine: startLine + lineIdx + 1, }; } checkboxes[checkboxIdx].tokens.push(...line.tokens); checkboxes[checkboxIdx].content += line.content + '\n'; checkboxes[checkboxIdx].endLine = startLine + lineIdx + 1; } return checkboxes; } function isChecked(value: string): boolean { return value === 'X' || value === 'x'; } function isBreakToken(token: Token): boolean { return token.type === 'softbreak' || token.type === 'hardbreak'; }