UNPKG

remark-flexible-containers

Version:

Remark plugin to add custom containers with customizable properties in markdown

634 lines (497 loc) 19.4 kB
import { CONTINUE, visit } from "unist-util-visit"; import type { Plugin, Transformer } from "unified"; import type { BlockContent, Data, Node, Paragraph, Parent, PhrasingContent, Root, Text, } from "mdast"; import { u } from "unist-builder"; import { findAfter } from "unist-util-find-after"; import { findAllBetween } from "unist-util-find-between-all"; 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 ContainerData extends Data {} interface Container extends Parent { /** * Node type of mdast Mark. */ type: "container"; /** * Children of paragraph. */ children: BlockContent[]; /** * Data associated with the mdast paragraph. */ data?: ContainerData | undefined; } declare module "mdast" { interface BlockContentMap { container: Container; } interface RootContentMap { container: Container; } } type TitleFunction = (type?: string, title?: string) => string | null | undefined; type TagNameFunction = (type?: string, title?: string) => string; type ClassNameFunction = (type?: string, title?: string) => string[]; type PropertyFunction = (type?: string, title?: string) => RestrictedRecord; type RestrictedRecord = Record<string, unknown> & { className?: never }; export type FlexibleContainerOptions = { title?: TitleFunction; containerTagName?: string | TagNameFunction; containerClassName?: string | ClassNameFunction; containerProperties?: PropertyFunction; titleTagName?: string | TagNameFunction; titleClassName?: string | ClassNameFunction; titleProperties?: PropertyFunction; }; const DEFAULT_SETTINGS: FlexibleContainerOptions = { containerTagName: "div", containerClassName: "remark-container", titleTagName: "div", titleClassName: "remark-container-title", }; type PartiallyRequiredFlexibleContainerOptions = Prettify< PartiallyRequired< FlexibleContainerOptions, "containerTagName" | "containerClassName" | "titleTagName" | "titleClassName" > >; 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: Plugin<[FlexibleContainerOptions?], Root> = (options) => { const settings = Object.assign( {}, DEFAULT_SETTINGS, options, ) as PartiallyRequiredFlexibleContainerOptions; const constructTitle = ( type?: string, title?: string, props?: string[], ): Paragraph | undefined => { 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: Record<string, unknown> | undefined; if (settings.titleProperties) { properties = settings.titleProperties(_type, _title); 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"]; }); } 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: BlockContent[], type?: string, title?: string, props?: string[], ): Container => { 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: Record<string, unknown> | undefined; if (settings.containerProperties) { properties = settings.containerProperties(_type, _title); 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"]; }); } 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?: string): { containerProps: string[] | undefined; title: string | undefined; titleProps: string[] | undefined; } { 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: Paragraph): boolean { const firstElement = node.children[0]; if (firstElement.type !== "text") return false; if (REGEX_BAD_SYNTAX.test(firstElement.value)) { return false; } return firstElement.value.startsWith(":::"); } /** * {flag: "complete"} means it is complete, so the end marker ":::" is FOUND in the current node; and the current node is MUTATED * {flag: "mutated"} means the end marker ":::" is NOT FOUND in the current node; and the current node is MUTATED * {flag: "regular"} means it is a regular container starter; and the current node is NOT MUTATED */ type AnalyzeResult = { flag: "complete" | "mutated" | "regular"; type?: string; rawtitle?: string; }; /** * * if the paragraph node has one child (as Text), * control whether the node has end marker ":::" or not (check completeness) */ function analyzeChild(node: Paragraph): AnalyzeResult { const textElement = node.children[0] as Text; // it is guarenteed in "checkTarget" let flag: AnalyzeResult["flag"] | undefined = undefined; let type: string | undefined = undefined; let title: string | undefined = undefined; let nIndex: number | undefined = 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: Paragraph): AnalyzeResult { const firstElement = node.children[0] as Text; // it is guarenteed in "checkTarget" let flag: AnalyzeResult["flag"] = "mutated"; // it has more children means it can not be "regular" let type: string | undefined = undefined; let title: string | undefined = undefined; let nIndex: number | undefined = undefined; const paragraphChildren: PhrasingContent[] = []; 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: Paragraph): AnalyzeResult["flag"] { 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: Paragraph): boolean { 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: Paragraph): undefined { if (node.children[0].type === "break") { node.children.shift(); } } /** * * type predicate function */ function is<T extends Node>(node: Node, type: string): node is T { return node.type === type; } const transformer: Transformer<Root> = (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 as Paragraph).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<Paragraph>(closingNode, "paragraph")) return; const closingFlag = analyzeClosingNode(closingNode); // mutates the closingNode const containerChildren = findAllBetween( parent, openingNode, closingNode, ) as BlockContent[]; 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;