remark-flexible-code-titles
Version:
Remark plugin to add title or/and container for code blocks with customizable properties in markdown
209 lines (208 loc) • 8.02 kB
JavaScript
import { visit } from "unist-util-visit";
const DEFAULT_SETTINGS = {
title: true,
titleTagName: "div",
titleClassName: "remark-code-title",
container: true,
containerTagName: "div",
containerClassName: "remark-code-container",
};
/**
*
* This plugin adds a title element before the code element, if the title exists in the markdown code block;
* and wraps them in a container.
*
* for example:
* ```javascript:title.js
* // some js code
* ```
*/
export const plugin = (options) => {
const settings = Object.assign({}, DEFAULT_SETTINGS, options);
/** for creating mdx elements just in case (for archive)
const titleNode = {
type: "mdxJsxFlowElement",
name: "div",
attributes: [
{
type: "mdxJsxAttribute",
name: "className",
value: "remark-code-title",
},
{ type: "mdxJsxAttribute", name: "data-language", value: language },
],
children: [{ type: "text", value: title }],
data: { _xdmExplicitJsx: true },
};
const containerNode = {
type: "mdxJsxFlowElement",
name: "div",
attributes: [
{
type: "mdxJsxAttribute",
name: "className",
value: "remark-code-container",
},
],
children: [titleNode, node],
data: { _xdmExplicitJsx: true },
};
*/
const constructTitle = (language, title) => {
const properties = settings.titleProperties?.(language, title) ?? {};
Object.entries(properties).forEach(([k, v]) => {
if ((typeof v === "string" && v === "") ||
(Array.isArray(v) && v.length === 0)) {
properties[k] = undefined;
}
if (k === "className")
delete properties?.["className"];
});
return {
type: "paragraph",
children: [{ type: "text", value: title }],
data: {
hName: settings.titleTagName,
hProperties: {
className: [settings.titleClassName],
...(properties && { ...properties }),
},
},
};
};
const constructContainer = (children, language, title) => {
const properties = settings.containerProperties?.(language, title) ?? {};
Object.entries(properties).forEach(([k, v]) => {
if ((typeof v === "string" && v === "") ||
(Array.isArray(v) && v.length === 0)) {
properties[k] = undefined;
}
if (k === "className")
delete properties?.["className"];
});
return {
type: "container",
children,
data: {
hName: settings.containerTagName,
hProperties: {
className: [settings.containerClassName],
...(properties && { ...properties }),
},
},
};
};
const extractLanguageAndTitle = (node) => {
const { lang: inputLang, meta: inputMeta } = node;
if (!inputLang) {
return { language: null, title: null, meta: null };
}
// we know that "lang" doesn't contain a space (gfm code fencing), but "meta" may consist.
let title = null;
let language = inputLang;
let meta = inputMeta ?? null;
// move "showLineNumbers" into meta
if (/showLineNumbers/.test(language)) {
language = language.replace(/showLineNumbers/, "");
meta = meta?.length ? meta + " showLineNumbers" : "showLineNumbers";
}
// move line range string like {1, 3-4} into meta (it may complete or nor)
if (language.includes("{")) {
const idxStart = language.search("{");
const idxEnd = language.search("}");
const metaPart = idxEnd >= 0
? language.substring(idxStart, idxEnd + 1)
: language.slice(idxStart, language.length);
language = language.replace(metaPart, "");
meta = meta?.length ? metaPart + meta : metaPart;
}
// move colon+title into meta
if (language.includes(":")) {
const idx = language.search(":");
const metaPart = language.slice(idx, language.length);
language = language.slice(0, idx);
meta = meta?.length ? metaPart + " " + meta : metaPart;
}
// another correctness for line ranges, removing all spaces within curly braces
const RE = /{([\d\s,-]+)}/g; // finds {1, 2-4} like strings for line highlighting
if (meta?.length && RE.test(meta)) {
meta = meta.replace(RE, function (match) {
return match.replace(/ /g, "");
});
}
// correct if there is a non-space character before opening curly brace "{"
meta = meta?.replace(/(?<=\S)\{/, " {") ?? null;
// correct if there is a non-space character after closing curly brace "}"
meta = meta?.replace(/\}(?=\S)/, "} ") ?? null;
if (meta?.includes(":")) {
const regex = /:\s*.*?(?=[\s{]|$)/; // to find :title with colon
const match = meta?.match(regex);
// classic V8 coverage false negative
/* v8 ignore next -- @preserve */
if (match) {
const matched = match[0];
title = matched.replace(/:\s*/, "");
if (/^showLineNumbers$/i.test(title)) {
title = null;
meta = meta.replace(/:\s*/, "");
}
else {
meta = meta.replace(matched, "");
}
}
}
// handle missing language
if (!language &&
settings.handleMissingLanguageAs &&
typeof settings.handleMissingLanguageAs === "string") {
language = settings.handleMissingLanguageAs;
}
// remove if there is more spaces in meta
meta = meta?.replace(/\s+/g, " ").trim() ?? null;
// employ the settings.tokenForSpaceInTitle
if (title && settings.tokenForSpaceInTitle)
title = title.replaceAll(settings.tokenForSpaceInTitle, " ");
// if the title is empty, make it null
if (title?.trim() === "")
title = null;
// if the language is empty, make it null
if (language === "")
language = null;
// if the meta is empty, make it null
if (meta === "")
meta = null;
return { title, language, meta };
};
const visitor = function (node, index, parent) {
/* v8 ignore next -- @preserve */
if (!parent || typeof index === "undefined")
return;
const { title, language, meta } = extractLanguageAndTitle(node);
// mutating the parent.children may effect the next iteration causing visit the same node "code"
// so, it is important to normalize the language here, otherwise may cause infinite loop
node.lang = language;
node.meta = meta;
let titleNode = undefined;
let containerNode = undefined;
if (settings.title && title) {
titleNode = constructTitle(language ?? "", title);
}
if (settings.container) {
containerNode = constructContainer(titleNode ? [titleNode, node] : [node], language ?? "", title ?? "");
}
if (containerNode) {
// 1 is for replacing the "code" with the container which consists it already
parent.children.splice(index, 1, containerNode);
}
else if (titleNode) {
// 0 is for inserting the titleNode before the "code"
parent.children.splice(index, 0, titleNode);
}
};
const transformer = (tree) => {
visit(tree, "code", visitor);
};
return transformer;
};
export default plugin;
//# sourceMappingURL=index.js.map