remark-flexible-containers
Version:
Remark plugin to add custom containers with customizable properties in markdown
427 lines • 18.6 kB
JavaScript
import { visit, CONTINUE } from "unist-util-visit";
import { u } from "unist-builder";
import { findAfter } from "unist-util-find-after";
import { findBetween } from "unist-util-find-between";
const DEFAULT_SETTINGS = {
containerTagName: "div",
containerClassName: "remark-container",
titleTagName: "div",
titleClassName: "remark-container-title",
};
// ---- Type Predicates ---------------------------------------------------------------
function is(node, type) {
return node.type === type;
}
// ---- Regexes ---------------------------------------------------------------
// Capture colons >=3 (opening fence), optional type, optional title (trailing spaces NOT allowed)
export const REGEX_START = /^(:{3,})\s*([\w-]+)?\s*(.*[^ \n])?/u;
// Generic end matcher (any fence >=3 colons at end), no need capturing the length of colons
export const REGEX_END = /\s*\n*?:{3,}$/;
// actual closing detection is done dynamically per fence length
const get_REGEX_END = (fence) => new RegExp(`\\s*\\n*?${fence}$`);
// for gating for bad syntax
export const REGEX_BAD_SYNTAX = /^:{3,}\s*\n+\s*:{3,}\s*.*/;
// actual bad syntax detection is done dynamically per same fence length
const get_REGEX_BAD_SYNTAX = (fence) => new RegExp(`^${fence}\\s*\\n+\\s*${fence}\\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);
// ---- Helpers ----------------------------------------------------------
const REVISIT = (i) => i;
/**
*
* normalize specific identifiers "{section#id.classname}" --> "section #id .classname"
*
*/
function normalizeIdentifiers(input) {
return input
?.replace(/[{}]/g, "")
.replace(/\./g, " .")
.replace(/#/g, " #")
.replace(/@/g, " @")
.replace(/\s+/g, " ")
.trim();
}
/**
*
* if the paragraph has one child,
* check weather it has only one Text child and its value is empty string
*
*/
function checkParagraphWithEmptyText(node) {
return (node.children.length === 1 &&
node.children[0].type === "text" &&
node.children[0].value === "");
}
/**
*
* if the first child of paragraph children is "break", then remove that child
*
*/
function removeFirstBreakInPlace(node) {
if (node.children[0].type === "break") {
node.children.shift();
}
}
/**
*
* merge properties
*
*/
function mergeProperties(id, classnames, attributes, baseProperties) {
const properties = {};
if (id)
properties.id = id;
// classic V8 coverage false negative
/* v8 ignore next -- @preserve */
if (classnames && classnames.length) {
properties.className = [...classnames];
}
if (baseProperties) {
for (const [k, v] of Object.entries(baseProperties)) {
if (k === "className")
continue; // never accept className from settings-Properties
if (typeof v === "string" && v === "")
continue;
if (Array.isArray(v) && v.length === 0)
continue;
properties[k] = v;
}
}
// key or key=value array
if (attributes?.length) {
attributes.forEach((attr) => {
const [rawKey, ...rest] = attr.split("=");
const key = rawKey.trim();
if (!key)
return; // Ignore empty keys
const value = rest.length ? rest.join("=").trim() : true; // allow '=' inside value
properties[key] = value;
});
}
return properties;
}
// ---- Constructors And Utils -------------------------------------------------------
const constructTitle = (type, title, props) => {
const _type = type?.toLowerCase();
const _title = title?.replace(/\s+/g, " ");
const optionTitle = settings.title?.(_type, _title);
// if the option is `title: () => null`, suppress title unless explicit props exist
if (!props && optionTitle === null && _type !== "details")
return;
const mainTitle = optionTitle ?? _title ?? (_type === "details" ? "Details" : undefined);
if (!mainTitle)
return;
const titleTagName = typeof settings.titleTagName === "string"
? _type === "details"
? "summary"
: settings.titleTagName
: settings.titleTagName(_type, _title);
const titleClassName = typeof settings.titleClassName === "string"
? _type === "details"
? ["remark-summary"]
: [settings.titleClassName, _type ?? ""]
: settings.titleClassName(_type, _title);
// props may contain specific identifiers (tagname, id, classnames) specific to this title node
const specificTagName = props?.find((p) => /^[^#.@]/.test(p));
const specificId = props?.find((p) => p.startsWith("#"))?.slice(1);
const specificClassNames = props?.filter((p) => p.startsWith("."))?.map((p) => p.slice(1));
const specificAttributes = props?.filter((p) => p.startsWith("@"))?.map((p) => p.slice(1));
const mergedClassNames = [...titleClassName, ...(specificClassNames ?? [])];
const baseProps = settings.titleProperties?.(_type, _title);
return {
type: "paragraph",
children: [{ type: "text", value: mainTitle }],
data: {
hName: specificTagName ?? titleTagName,
hProperties: mergeProperties(specificId, mergedClassNames, specificAttributes, baseProps),
},
};
};
const constructContainer = (children, type, title, props) => {
const _type = type?.toLowerCase();
const _title = title?.replace(/\s+/g, " ");
const containerTagName = typeof settings.containerTagName === "string"
? _type === "details"
? "details"
: settings.containerTagName
: settings.containerTagName(_type, _title);
const containerClassName = typeof settings.containerClassName === "string"
? _type === "details"
? ["remark-details"]
: [settings.containerClassName, _type ?? ""]
: settings.containerClassName(_type, _title);
// props may contain specific identifiers (tagname, id, classnames) specific to this container node
const specificTagName = props?.find((p) => /^[^#.@]/.test(p));
const specificId = props?.find((p) => p.startsWith("#"))?.slice(1);
const specificClassNames = props?.filter((p) => p.startsWith("."))?.map((p) => p.slice(1));
const specificAttributes = props?.filter((p) => p.startsWith("@"))?.map((p) => p.slice(1));
const mergedClassNames = [...containerClassName, ...(specificClassNames ?? [])];
const baseProps = settings.containerProperties?.(_type, _title);
return {
type: "container",
children,
data: {
hName: specificTagName ?? containerTagName,
hProperties: mergeProperties(specificId, mergedClassNames, specificAttributes, baseProps),
},
};
};
/**
*
* extract specific identifiers in curly braces for container and title nodes
* ::: type {section#foo} title {span.bar}
*
*/
function extractSpecificIdentifiers(input) {
if (!input)
return {};
const match = input.match(REGEX_CUSTOM);
const nContainerFixture = normalizeIdentifiers(match?.[1]);
const nMainTitle = normalizeIdentifiers(match?.[2]);
const nTitleFixture = normalizeIdentifiers(match?.[3]);
const containerProps = (nContainerFixture || undefined)?.split(" ");
const title = nMainTitle || undefined;
const titleProps = (nTitleFixture || undefined)?.split(" ");
return { containerProps, title, titleProps };
}
/**
*
* get the opening fence string (>=3 colons)
* if present at the start of a paragraph’s first Text child and not “bad syntax”
*
*/
function getOpeningFence(node) {
const firstElement = node.children[0];
if (firstElement.type !== "text")
return;
const match = firstElement.value.match(/^:{3,}/u);
const fence = match ? match[0] : undefined;
if (fence && get_REGEX_BAD_SYNTAX(fence).test(firstElement.value))
return;
return fence;
}
/**
*
* Parses the initial line of a fence block to extract the type, title,
* and the remaining content.
*
*/
function parseFenceTypeTitle(initialValue, fence) {
let type;
let title;
if (!initialValue.includes("\n")) {
const match = initialValue.match(REGEX_START);
return { type: match[2], title: match[3], rest: undefined };
}
let value = initialValue
.replace(new RegExp(`^${fence}`), "")
// remove (space, tab) but exclude \r and \n in the beginning
.replace(/^[^\S\r\n]+/, "");
const nIndex = value.indexOf("\n");
if (nIndex === 0) {
// means that there is no "type" and "title"
// remove the newline "\n" in the beginning
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); // extracted \n from the beginning
}
return { type, title, rest: value };
}
/**
*
* Parses opening fence line; mutates the node’s children accordingly
*
* if the paragraph node has one child (as Text),
* check whether the node has end marker ":::" or not (check completeness)
*
*/
function analyzeChild(node, fence) {
const textElement = node.children[0]; // it is guarenteed in "getOpeningFence"
const { type, title, rest } = parseFenceTypeTitle(textElement.value, fence);
if (!rest) {
// It is regular container
return { flag: "regular", type, rawtitle: title };
}
let flag;
let remaining;
if (rest.endsWith(fence)) {
// the container ends within same paragraph's text node
flag = "complete";
remaining = rest.slice(0, -fence.length).trim(); // remove the "\n:::" at the end
}
else {
// the container is opened but not closed in this text node
flag = "mutated";
remaining = rest;
}
textElement.value = remaining; // mutation
return { flag, type, rawtitle: title };
}
/**
*
* Parses opening fence line; mutates the node’s children accordingly
*
* if the paragraph node has more than one child,
* check whether the node's last child has end marker ":::" or not (check completeness)
*
*/
function analyzeChildren(node, fence) {
const firstElement = node.children[0]; // it is guarenteed in "getOpeningFence"
let flag = "mutated"; // it has more children means it can not be "regular"
const paragraphChildren = [];
const { type, title, rest } = parseFenceTypeTitle(firstElement.value, fence);
if (rest) {
// mutate the first Phrase
firstElement.value = rest;
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];
// check weather the paragraph has closing marker or not (check completeness)
if (lastElement.type === "text" && lastElement.value.endsWith("\n" + fence)) {
// the container ends within the same paragraph
flag = "complete";
// mutate the last Phrase
lastElement.value = lastElement.value.slice(0, -(fence.length + 1));
}
paragraphChildren.push(lastElement);
node.children = paragraphChildren; // mutation
return { flag, type, rawtitle: title };
}
/**
*
* if the paragraph has one Text child,
* check it has closing ":::" or not (check completeness)
*
*/
function analyzeClosingNode(node, fence) {
const { children } = node;
const lastChild = children[children.length - 1];
// classic V8 coverage false negative
/* v8 ignore next -- @preserve */
if (lastChild.type === "text") {
if (children.length === 1 && lastChild.value === fence) {
return "regular";
}
lastChild.value = lastChild.value.replace(get_REGEX_END(fence), "");
if (!lastChild.value) {
node.children.pop();
}
}
if (children.length > 0) {
return "mutated";
}
else {
return "regular";
}
}
const transformer = (tree) => {
// if a html node.value ends with a new line and colons >=3 like "\n:::", remove and carry it into a new paragraph
visit(tree, "html", function (node, index, parent) {
/* v8 ignore next -- @preserve */
if (!parent || typeof index === "undefined")
return;
const match = node.value.match(/\n(:{3,})$/);
if (!match)
return;
node.value = node.value.replace(new RegExp(`\\n${match[1]}$`), "");
const p = u("paragraph", [u("text", "\n" + match[1])]);
// 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 -- @preserve */
if (!parent || typeof index === "undefined")
return;
const fence = getOpeningFence(node);
if (!fence)
return;
const { flag, type, rawtitle } = node.children.length === 1
? analyzeChild(node, fence) // mutates the node
: analyzeChildren(node, fence); // mutates the node
const { containerProps, title, titleProps } = extractSpecificIdentifiers(rawtitle?.trim());
if (flag === "complete") {
// means that the container starts and ends within the same paragraph node
const titleNode = constructTitle(type, title, titleProps);
removeFirstBreakInPlace(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(get_REGEX_END(fence)));
});
if (!closingNode)
return;
// just for type predicate
/* v8 ignore next -- @preserve */
if (!is(closingNode, "paragraph"))
return;
const closingFlag = analyzeClosingNode(closingNode, fence); // mutates the closingNode
const containerChildren = findBetween(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);
// Revisit position where the new node was inserted (key for nesting, instead of returning CONTINUE;)
return REVISIT(openingIndex);
});
};
return transformer;
};
export default plugin;
//# sourceMappingURL=index.js.map