@diplodoc/transform
Version:
A simple transformer of text in YFM (Yandex Flavored Markdown) to HTML
219 lines (187 loc) • 6.86 kB
text/typescript
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';
}