UNPKG

remark-flexible-markers

Version:

Remark plugin to add custom mark element with customizable properties in markdown

479 lines (384 loc) 13.3 kB
import { CONTINUE, SKIP, visit } from "unist-util-visit"; import type { Visitor, VisitorResult } from "unist-util-visit"; import type { Plugin, Transformer } from "unified"; import type { Data, Parent, PhrasingContent, Root, Text } from "mdast"; import { findAllBetween } from "unist-util-find-between-all"; import { findAllBefore } from "unist-util-find-all-before"; import { findAllAfter } from "unist-util-find-all-after"; import { findAfter } from "unist-util-find-after"; import { u } from "unist-builder"; type Prettify<T> = { [K in keyof T]: T[K] } & {}; type PartiallyRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>; // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface MarkData extends Data {} interface Mark extends Parent { /** * Node type of mdast Mark. */ type: "mark"; /** * Children of paragraph. */ children: PhrasingContent[]; /** * Data associated with the mdast paragraph. */ data?: MarkData | undefined; } declare module "mdast" { interface PhrasingContentMap { mark: Mark; } interface RootContentMap { mark: Mark; } } // satisfies the regex [a-z] type Key = | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z"; type Dictionary = Partial<Record<Key, string>>; const dictionary: Dictionary = { a: "amber", b: "blue", c: "cyan", d: "brown", e: "espresso", f: "fuchsia", g: "green", h: "hotpink", i: "indigo", j: "jade", k: "kiwi", l: "lime", m: "magenta", n: "navyblue", o: "orange", p: "purple", q: "pink", r: "red", s: "silver", t: "teal", u: "umber", v: "violet", w: "white", x: "gray", y: "yellow", z: "black", }; type RestrictedRecord = Record<string, unknown> & { className?: never }; type TagNameFunction = (color?: string) => string; type ClassNameFunction = (color?: string) => string[]; type PropertyFunction = (color?: string) => RestrictedRecord; export type FlexibleMarkerOptions = { dictionary?: Dictionary; markerTagName?: string | TagNameFunction; markerClassName?: string | ClassNameFunction; markerProperties?: PropertyFunction; equalityOperator?: string; actionForEmptyContent?: "keep" | "remove" | "mark"; }; const DEFAULT_SETTINGS: FlexibleMarkerOptions = { dictionary, markerTagName: "mark", markerClassName: "flexible-marker", actionForEmptyContent: "mark", }; type PartiallyRequiredFlexibleMarkerOptions = Prettify< PartiallyRequired< FlexibleMarkerOptions, "dictionary" | "markerTagName" | "markerClassName" | "actionForEmptyContent" > >; // the previous regex was not strict related with spaces // export const REGEX = /=([a-z]?)=\s*([^=]*[^ ])?\s*==/; // export const REGEX_GLOBAL = /=([a-z]?)=\s*([^=]*[^ ])?\s*==/g; // the new regex is strict! // it doesn't allow a space after the first double equity sign // it doesn't allow a space before the last double equity sign export const REGEX = /=([a-z]?)=(?![\s=])([\s\S]*?)(?<![\s=])==/; export const REGEX_GLOBAL = /=([a-z]?)=(?![\s=])([\s\S]*?)(?<![\s=])==/g; export const REGEX_STARTING = /=([a-z]?)=(?![\s]|=+\s)/; export const REGEX_STARTING_GLOBAL = /=([a-z]?)=(?![\s]|=+\s)/g; export const REGEX_ENDING = /(?<!\s|\s=|\s==|\s===|\s====)==/; export const REGEX_ENDING_GLOBAL = /(?<!\s|\s=|\s==|\s===|\s====)==/g; export const REGEX_EMPTY = /=([a-z]?)=\s*==/; export const REGEX_EMPTY_GLOBAL = /=([a-z]?)=\s*==/g; /** * * a utility like "clsx" package */ export function clsx<T>(arr: (T | false | null | undefined | 0)[]): T[] { return arr.filter((item): item is T => !!item); } /** * * This plugin turns ==content== into a <mark> element with customizable classification * * for example: * * Here is ==marked text with default color== * Here is =r=marked text with red classification== * */ const plugin: Plugin<[FlexibleMarkerOptions?], Root> = (options) => { const settings = Object.assign( {}, DEFAULT_SETTINGS, options, ) as PartiallyRequiredFlexibleMarkerOptions; if (options?.dictionary && Object.keys(options.dictionary).length) { settings.dictionary = Object.assign({}, dictionary, options.dictionary); } /** * * constracts the custom Mark node as a MDAST node * */ const constructMarkNode = ( classification: Key | undefined, children: PhrasingContent[], ): Mark => { const color = classification ? settings.dictionary[classification] : undefined; const markerTagName = typeof settings.markerTagName === "string" ? settings.markerTagName : settings.markerTagName(color); const markerClassName = typeof settings.markerClassName === "function" ? settings.markerClassName(color) : clsx<string>([ settings.markerClassName, !classification && `${settings.markerClassName}-default`, color && `${settings.markerClassName}-${color}`, !children.length && `${settings.markerClassName}-empty`, ]); let properties: Record<string, unknown> | undefined; if (settings.markerProperties) { properties = settings.markerProperties(color); Object.entries(properties).forEach(([k, v]) => { if ( (typeof v === "string" && v === "") || (Array.isArray(v) && (v as unknown[]).length === 0) ) { if (properties) { properties[k] = undefined; } } if (k === "className") delete properties?.["className"]; }); } // https://github.com/syntax-tree/mdast-util-to-hast#example-supporting-custom-nodes return { type: "mark", children, data: { hName: markerTagName, hProperties: { className: markerClassName, ...(properties && { ...properties }), }, }, }; }; /** * * visits the Text nodes to match with the mark syntax (==marked text content==) * */ const visitorFirst: Visitor<Text, Parent> = function (node, index, parent): VisitorResult { /* v8 ignore next */ if (!parent || typeof index === "undefined") return; if (!REGEX.test(node.value)) return; const children: Array<PhrasingContent> = []; const value = node.value; let tempValue = ""; let prevMatchIndex = 0; let prevMatchLength = 0; const matches = Array.from(value.matchAll(REGEX_GLOBAL)); for (let index = 0; index < matches.length; index++) { const match = matches[index]; const [matched, classification, markedText] = match; const mIndex = match.index; const mLength = matched.length; // could be a text part before each matched part const textPartIndex = prevMatchIndex + prevMatchLength; prevMatchIndex = mIndex; prevMatchLength = mLength; // if there is a text part before if (mIndex > textPartIndex) { const textValue = value.substring(textPartIndex, mIndex); const textNode = u("text", textValue); children.push(textNode); } const markerNode = constructMarkNode(classification as Key, [ { type: "text", value: markedText.trim() }, ]); children.push(markerNode); // control for the last text node if exists after the last match tempValue = value.slice(mIndex + mLength); } // if there is still text after the last match if (tempValue) { const textNode = u("text", tempValue); children.push(textNode); } if (children.length) parent.children.splice(index, 1, ...children); }; /** * * visits the Text nodes to find the mark syntax (==marked **text** content==) * if parent contains other content phrases * */ const visitorSecond: Visitor<Text, Parent> = function (node, index, parent): VisitorResult { /* v8 ignore next */ if (!parent || typeof index === "undefined") return; // control if the Text node matches with "starting mark regex" if (!REGEX_STARTING.test(node.value)) return; const openingNode = node; // control if any next child Text node of the parent has "ending mark regex" const closingNode = findAfter(parent, openingNode, function (node) { return node.type === "text" && REGEX_ENDING.test((node as Text).value); }); if (!closingNode) return; // now, ensured that the parent has a mark element between opening Text node and closing Text nodes const beforeChildren = findAllBefore(parent, openingNode) as PhrasingContent[]; const markChildren = findAllBetween(parent, openingNode, closingNode) as PhrasingContent[]; const afterChildren = findAllAfter(parent, closingNode) as PhrasingContent[]; /********************* OPENING NODE ***********************/ // let's analyze the opening Text node const value = openingNode.value; const match = Array.from(value.matchAll(REGEX_STARTING_GLOBAL))[0]; const [matched, classification] = match; const mLength = matched.length; const mIndex = match.index; // if there is a text part before if (mIndex > 0) { const textValue = value.substring(0, mIndex); const textNode = u("text", textValue); beforeChildren.push(textNode); } // if there is a text part after if (value.length > mIndex + mLength) { const textValue = value.slice(mIndex + mLength); const textNode = u("text", textValue); markChildren.unshift(textNode); } /********************* CLOSING NODE ***********************/ // let's analyze the closing Text node const value_ = (closingNode as Text).value; const match_ = Array.from(value_.matchAll(REGEX_ENDING_GLOBAL))[0]; const [matched_] = match_; const mLength_ = matched_.length; const mIndex_ = match_.index; // if there is a text part before if (mIndex_ > 0) { const textValue = value_.substring(0, mIndex_); const textNode = u("text", textValue); markChildren.push(textNode); } // if there is a text part after if (value_.length > mIndex_ + mLength_) { const textValue = value_.slice(mIndex_ + mLength_); const textNode = u("text", textValue); afterChildren.unshift(textNode); } // now it is time to construct a mark node const markNode = constructMarkNode(classification as Key, markChildren); parent.children = [...beforeChildren, markNode, ...afterChildren]; return index; // in order to re-visit the same node and children }; /** * * visits the Text nodes to find empty markers (==== or == ==) * */ const visitorThird: Visitor<Text, Parent> = function (node, index, parent): VisitorResult { /* v8 ignore next */ if (!parent || typeof index === "undefined") return; if (!REGEX_EMPTY.test(node.value)) return; if (settings.actionForEmptyContent === "remove") { node.value = node.value.replaceAll(REGEX_EMPTY_GLOBAL, ""); // https://unifiedjs.com/learn/recipe/remove-node/ if (node.value.trim() === "") return [SKIP, index]; return CONTINUE; } const children: Array<PhrasingContent> = []; const value = node.value; let tempValue = ""; let prevMatchIndex = 0; let prevMatchLength = 0; const matches = Array.from(value.matchAll(REGEX_EMPTY_GLOBAL)); for (let index = 0; index < matches.length; index++) { const match = matches[index]; const [matched, classification] = match; const mIndex = match.index; const mLength = matched.length; // could be a text part before each matched part const textPartIndex = prevMatchIndex + prevMatchLength; prevMatchIndex = mIndex; prevMatchLength = mLength; // if there is a text part before if (mIndex > textPartIndex) { const textValue = value.substring(textPartIndex, mIndex); const textNode = u("text", textValue); children.push(textNode); } // empty marker const markerNode = constructMarkNode(classification as Key, []); children.push(markerNode); // control for the last text node if exists after the last match tempValue = value.slice(mIndex + mLength); } // if there is still text after the last match if (tempValue) { const textNode = u("text", tempValue); children.push(textNode); } if (children.length) parent.children.splice(index, 1, ...children); }; const transformer: Transformer<Root> = (tree) => { // to find markers in a Text node visit(tree, "text", visitorFirst); // to find markers if the parent contains other content phrases visit(tree, "text", visitorSecond); // to find empty markers (==== or == ==) if (settings.actionForEmptyContent !== "keep") { visit(tree, "text", visitorThird); } // to correct the mathematical double equity signs if (settings.equalityOperator) { const REGEX_EQUALITY = new RegExp(settings.equalityOperator, "gi"); visit(tree, "text", (node) => { node.value = node.value.replaceAll(REGEX_EQUALITY, "=="); }); } }; return transformer; }; export default plugin;