remark-flexible-containers
Version:
Remark plugin to add custom containers with customizable properties in markdown
415 lines • 18.1 kB
JavaScript
import { CONTINUE, visit } from "unist-util-visit";
import { u } from "unist-builder";
import { findAfter } from "unist-util-find-after";
import { findAllBetween } from "unist-util-find-between-all";
const DEFAULT_SETTINGS = {
containerTagName: "div",
containerClassName: "remark-container",
titleTagName: "div",
titleClassName: "remark-container-title",
};
export const REGEX_START = /^(:{3})\s*(\w+)?\s*(.*[^ \n])?/u;
export const REGEX_END = /\s*\n*?:::$/;
export const REGEX_BAD_SYNTAX = /^:::\s*\n+\s*:::\s*.*/;
// to find specific identifiers in curly braces --> {article#foo} Title {span.bar}
export const REGEX_CUSTOM = /(\{[^{}]*\})?(\s*[^{}]*\s*)?(\{[^{}]*\})?/u;
/**
*
* This plugin adds container node with customizable properties in order to produce container element like callouts and admonitions
*
* for example:
*
* ::: warning My Title
* Content with **bold text**
* :::
*
*/
export const plugin = (options) => {
const settings = Object.assign({}, DEFAULT_SETTINGS, options);
const constructTitle = (type, title, props) => {
const _type = type?.toLowerCase();
const _title = title?.replace(/\s+/g, " ");
const _settingsTitle = settings.title?.(_type, _title);
// if the option is `title: () => null`, then return; but props breaks the rule !
if (!props && _settingsTitle === null)
return;
const mainTitle = _settingsTitle || _title;
if (!mainTitle)
return;
// props may contain specific identifiers (tagname, id, classnames) specific to this title node
const specificTagName = props?.filter((p) => /^[^#.]/.test(p))?.[0];
const specificId = props?.filter((p) => p.startsWith("#"))?.[0]?.slice(1);
const specificClassName = props?.filter((p) => p.startsWith("."))?.map((p) => p.slice(1));
let properties;
if (settings.titleProperties) {
properties = settings.titleProperties(_type, _title);
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"];
});
}
const titleTagName = typeof settings.titleTagName === "string"
? settings.titleTagName
: settings.titleTagName(_type, _title);
const titleClassName = typeof settings.titleClassName === "string"
? [settings.titleClassName, _type ?? ""]
: [...settings.titleClassName(_type, _title)];
return {
type: "paragraph",
children: [{ type: "text", value: mainTitle }],
data: {
hName: specificTagName ?? titleTagName,
hProperties: {
className: [...titleClassName, ...(specificClassName ?? [])],
...(properties && { ...properties }),
...(specificId && { id: specificId }),
},
},
};
};
const constructContainer = (children, type, title, props) => {
const _type = type?.toLowerCase();
const _title = title?.replace(/\s+/g, " ");
// props may contain specific identifiers (tagname, id, classnames) specific to this container node
const specificTagName = props?.filter((p) => /^[^#.]/.test(p))?.[0];
const specificId = props?.filter((p) => p.startsWith("#"))?.[0]?.slice(1);
const specificClassName = props?.filter((p) => p.startsWith("."))?.map((p) => p.slice(1));
let properties;
if (settings.containerProperties) {
properties = settings.containerProperties(_type, _title);
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"];
});
}
const containerTagName = typeof settings.containerTagName === "string"
? settings.containerTagName
: settings.containerTagName(_type, _title);
const containerClassName = typeof settings.containerClassName === "string"
? [settings.containerClassName, _type ?? ""]
: [...settings.containerClassName(_type, _title)];
return {
type: "container",
children,
data: {
hName: specificTagName ?? containerTagName,
hProperties: {
className: [...containerClassName, ...(specificClassName ?? [])],
...(properties && { ...properties }),
...(specificId && { id: specificId }),
},
},
};
};
// Define a custom string method
String.prototype.normalize = function () {
return this?.replace(/[{}]/g, "")
.replace(".", " .")
.replace("#", " #")
.replace(/\s+/g, " ")
.trim();
};
/**
* the matched title may contain specific identifiers for container and title node
* in curly braces like: {section#foo} Title {span.bar}
*
*/
function getSpecificIdentifiers(input) {
if (!input)
return { containerProps: undefined, title: undefined, titleProps: undefined };
const match = input.match(REGEX_CUSTOM);
/* eslint-disable */
/* v8 ignore next */
let [input_, containerFixture, mainTitle, titleFixture] = match ?? [undefined];
/* eslint-enable */
containerFixture = containerFixture?.normalize();
const containerProps = containerFixture && containerFixture !== "" ? containerFixture?.split(" ") : undefined;
titleFixture = titleFixture?.normalize();
const titleProps = titleFixture && titleFixture !== "" ? titleFixture?.split(" ") : undefined;
mainTitle = mainTitle?.normalize();
mainTitle = mainTitle === "" ? undefined : mainTitle;
return { containerProps, title: mainTitle, titleProps };
}
/**
*
* checks the paragraph node starts with a Text Node;
* and checks the value starts with container start marker.
*/
function checkIsTarget(node) {
const firstElement = node.children[0];
if (firstElement.type !== "text")
return false;
if (REGEX_BAD_SYNTAX.test(firstElement.value)) {
return false;
}
return firstElement.value.startsWith(":::");
}
/**
*
* if the paragraph node has one child (as Text),
* control whether the node has end marker ":::" or not (check completeness)
*/
function analyzeChild(node) {
const textElement = node.children[0]; // it is guarenteed in "checkTarget"
let flag = undefined;
let type = undefined;
let title = undefined;
let nIndex = undefined; // for newline "\n" character
if (!textElement.value.includes("\n")) {
// It is regular container, meaningly, there is a blank line before the start marker ":::"
const match = textElement.value.match(REGEX_START);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [input, triplecolon, _type, _title] = match;
flag = "regular";
type = _type;
title = _title;
}
else {
// remove ":::" and whitespaces in the beginning
let value = textElement.value.replace(/^:::/, "").replace(/^[^\S\r\n]/, ""); // whitespaces not newline
nIndex = value.indexOf("\n");
if (nIndex === 0) {
// means that there is no "type" and "title"
// remove the newline "\n" in the beginning, and get the rest of the value
value = value.slice(1);
}
else {
// means that there is a "type" and/or a "title"
// get the type and the title
const params = value.substring(0, nIndex);
const match = params.match(/(\w+)\s*(.*[^\n ])?/u); // two matching groups: the first word and the rest
type = match[1];
title = match[2];
// remove upto newline "\n" (included) in the beginning, get the rest of the value
value = value.slice(nIndex + 1); // extraxted \n from the beginning
}
if (value.endsWith(":::")) {
// means that the container starts and ends within same paragraph's Text child
// remove the "\n:::" at the end
value = value.slice(0, -3).trim();
flag = "complete";
}
else {
flag = "mutated";
}
// mutate the current node
textElement.value = value;
}
return { flag, type, rawtitle: title };
}
/**
*
* if the paragraph node has more than one child,
* control whether the node's last child has end marker ":::" or not (check completeness)
*/
function analyzeChildren(node) {
const firstElement = node.children[0]; // it is guarenteed in "checkTarget"
let flag = "mutated"; // it has more children means it can not be "regular"
let type = undefined;
let title = undefined;
let nIndex = undefined;
const paragraphChildren = [];
if (!firstElement.value.includes("\n")) {
// means there is a Phrase other than Text Phrase after the line which has opening marker ":::"
const match = firstElement.value.match(REGEX_START);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [input, triplecolon, _type, _title] = match;
type = _type;
title = _title;
}
else {
// remove ":::" and whitespaces in the beginning
let value = firstElement.value.replace(/^:::/, "").replace(/^[^\S\r\n]/, ""); // whitespaces not newline
nIndex = value.indexOf("\n");
if (nIndex === 0) {
// means that there is no "type" and "title"
// remove the newline "\n" in the beginning, and get the rest of the value
value = value.slice(1);
}
else {
// means that there is a "type" and/or a "title"
// get the type and the title
const params = value.substring(0, nIndex);
const match = params.match(/(\w+)\s*(.*[^\n ])?/u); // two matching groups: the first word and the rest
type = match[1];
title = match[2];
// remove upto newline "\n" in the beginning, get the rest of the value
value = value.slice(nIndex + 1);
}
// mutate the first element value after extracting type and title
firstElement.value = value;
paragraphChildren.push(firstElement);
}
// push the Phrases after first Phrase up to last Phrase
for (let i = 1; i < node.children.length - 1; i++) {
paragraphChildren.push(node.children[i]);
}
const lastElement = node.children[node.children.length - 1];
// control weather has closing marker or not (check completeness)
if (lastElement.type === "text") {
if (lastElement.value.endsWith("\n:::")) {
flag = "complete";
// mutate the last Phrase
lastElement.value = lastElement.value.slice(0, -4);
}
paragraphChildren.push(lastElement);
}
else if (lastElement) {
paragraphChildren.push(lastElement);
}
// mutate the current paragraph children
node.children = paragraphChildren;
return { flag, type, rawtitle: title };
}
/**
*
* if the paragraph has one child (as Text),
* control weather has closing wither ":::" or not (check completeness)
*
*/
function analyzeClosingNode(node) {
const { children } = node;
const lastChild = children[children.length - 1];
if (lastChild.type === "text") {
if (children.length === 1 && lastChild.value === ":::") {
return "regular";
}
lastChild.value = lastChild.value.replace(REGEX_END, "");
if (!lastChild.value) {
node.children.pop();
}
}
if (children.length > 0) {
return "mutated";
}
else {
return "regular";
}
}
/**
*
* if the paragraph has one child,
* control wether it has only one child text, and the text value is empty string ""
*
*/
function checkParagraphWithEmptyText(node) {
if (node.children.length === 1 &&
node.children[0].type === "text" &&
node.children[0].value === "") {
return true;
}
return false;
}
/**
*
* if the first child of paragraph children is "break", then remove that child
*
*/
function deleteFirstChildBreak(node) {
if (node.children[0].type === "break") {
node.children.shift();
}
}
/**
*
* type predicate function
*/
function is(node, type) {
return node.type === type;
}
const transformer = (tree) => {
// if a html node.value ends with "\n:::", remove and carry it into a new paragraph
visit(tree, "html", function (node, index, parent) {
/* v8 ignore next */
if (!parent || typeof index === "undefined")
return;
if (!/\n:::$/.test(node.value))
return;
node.value = node.value.replace(/\n:::$/, "");
const p = u("paragraph", [u("text", "\n:::")]);
// add the paragraph after the html node, in order to the next visitor can catch the container node
parent.children.splice(index + 1, 0, p);
});
// main visit
visit(tree, "paragraph", function (node, index, parent) {
/* v8 ignore next */
if (!parent || typeof index === "undefined")
return;
const isTarget = checkIsTarget(node);
if (!isTarget)
return;
const { flag, type, rawtitle } = node.children.length === 1
? analyzeChild(node) // mutates the node
: analyzeChildren(node); // mutates the node
const { containerProps, title, titleProps } = getSpecificIdentifiers(rawtitle?.trim());
if (flag === "complete") {
// means that the container starts and ends within the same paragraph node
const titleNode = constructTitle(type, title, titleProps);
deleteFirstChildBreak(node); // mutates the node
const isParagraphWithEmptyText = checkParagraphWithEmptyText(node);
// is the paragraph node has only one child with empty text, don't add that paragraph node as a child
// meaningly, don't produce empty <p />
const containerChildren = isParagraphWithEmptyText
? [...(titleNode ? [titleNode] : [])]
: [...(titleNode ? [titleNode] : []), node];
const containerNode = constructContainer(containerChildren, type, title, containerProps);
// place it the place of the current paragraph node
parent.children.splice(index, 1, containerNode);
return CONTINUE;
}
const openingNode = node;
const openingFlag = flag;
const closingNode = findAfter(parent, openingNode, function (node) {
if (node.type !== "paragraph")
return false;
const pChildren = node.children;
const lastChild = pChildren[pChildren.length - 1];
if (lastChild.type !== "text")
return false;
return Boolean(lastChild.value.match(REGEX_END));
});
if (!closingNode)
return;
// just for type prediction
/* v8 ignore next */
if (!is(closingNode, "paragraph"))
return;
const closingFlag = analyzeClosingNode(closingNode); // mutates the closingNode
const containerChildren = findAllBetween(parent, openingNode, closingNode);
if (openingFlag === "mutated") {
containerChildren.unshift(openingNode);
}
if (closingFlag === "mutated") {
containerChildren.push(closingNode);
}
// if there is no content and type do not construct the container
if (!containerChildren.length && !type)
return;
// if there is no content but type, then continue to construct the container
const titleNode = constructTitle(type, title, titleProps);
if (titleNode)
containerChildren.splice(0, 0, titleNode);
const containerNode = constructContainer(containerChildren, type, title, containerProps);
const { children } = parent;
const openingIndex = children.indexOf(openingNode);
const closingIndex = children.indexOf(closingNode);
children.splice(openingIndex, closingIndex - openingIndex + 1, containerNode);
return CONTINUE;
});
};
return transformer;
};
export default plugin;
//# sourceMappingURL=index.js.map