remark-flexible-markers
Version:
Remark plugin to add custom mark element with customizable properties in markdown
309 lines • 12 kB
JavaScript
import { CONTINUE, SKIP, visit } from "unist-util-visit";
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";
const 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",
};
const DEFAULT_SETTINGS = {
dictionary,
markerTagName: "mark",
markerClassName: "flexible-marker",
actionForEmptyContent: "mark",
};
// 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(arr) {
return arr.filter((item) => !!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 = (options) => {
const settings = Object.assign({}, DEFAULT_SETTINGS, options);
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, children) => {
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([
settings.markerClassName,
!classification && `${settings.markerClassName}-default`,
color && `${settings.markerClassName}-${color}`,
!children.length && `${settings.markerClassName}-empty`,
]);
let properties;
if (settings.markerProperties) {
properties = settings.markerProperties(color);
Object.entries(properties).forEach(([k, v]) => {
if ((typeof v === "string" && v === "") ||
(Array.isArray(v) && v.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 = function (node, index, parent) {
/* v8 ignore next */
if (!parent || typeof index === "undefined")
return;
if (!REGEX.test(node.value))
return;
const children = [];
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, [
{ 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 = function (node, index, parent) {
/* 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.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);
const markChildren = findAllBetween(parent, openingNode, closingNode);
const afterChildren = findAllAfter(parent, closingNode);
/********************* 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.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, 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 = function (node, index, parent) {
/* 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 = [];
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, []);
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 = (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;
//# sourceMappingURL=index.js.map