remark-flexible-code-titles
Version:
Remark plugin to add title or/and container for code blocks with customizable properties in markdown
340 lines (280 loc) • 9.3 kB
text/typescript
import { visit, type Visitor } from "unist-util-visit";
import type { Plugin, Transformer } from "unified";
import type { Paragraph, Code, Root, Data, BlockContent, Parent } from "mdast";
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 StringOrNull = string | null;
type RestrictedRecord = Record<string, unknown> & { className?: never };
type PropertyFunction = (language?: string, title?: string) => RestrictedRecord;
export type CodeTitleOptions = {
title?: boolean;
titleTagName?: string;
titleClassName?: string;
titleProperties?: PropertyFunction;
container?: boolean;
containerTagName?: string;
containerClassName?: string;
containerProperties?: PropertyFunction;
handleMissingLanguageAs?: string;
tokenForSpaceInTitle?: string;
};
const DEFAULT_SETTINGS: CodeTitleOptions = {
title: true,
titleTagName: "div",
titleClassName: "remark-code-title",
container: true,
containerTagName: "div",
containerClassName: "remark-code-container",
};
type PartiallyRequiredCodeTitleOptions = Prettify<
PartiallyRequired<
CodeTitleOptions,
| "title"
| "titleTagName"
| "titleClassName"
| "container"
| "containerTagName"
| "containerClassName"
>
>;
/**
*
* 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: Plugin<[CodeTitleOptions?], Root> = (options) => {
const settings = Object.assign(
{},
DEFAULT_SETTINGS,
options,
) as PartiallyRequiredCodeTitleOptions;
/** 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: string, title: string): Paragraph => {
let properties: Record<string, unknown> | undefined;
if (settings.titleProperties) {
properties = settings.titleProperties(language, 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"];
});
}
return {
type: "paragraph",
children: [{ type: "text", value: title }],
data: {
hName: settings.titleTagName,
hProperties: {
className: [settings.titleClassName],
...(properties && { ...properties }),
},
},
};
};
const constructContainer = (
children: BlockContent[],
language: string,
title: string,
): Container => {
let properties: Record<string, unknown> | undefined;
if (settings.containerProperties) {
properties = settings.containerProperties(language, 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"];
});
}
return {
type: "container",
children,
data: {
hName: settings.containerTagName,
hProperties: {
className: [settings.containerClassName],
...(properties && { ...properties }),
},
},
};
};
const extractLanguageAndTitle = (node: Code) => {
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: StringOrNull = null;
let language: StringOrNull = inputLang;
let meta: StringOrNull = 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);
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: Visitor<Code> = function (node, index, parent) {
/* v8 ignore next */
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: Paragraph | undefined = undefined;
let containerNode: Container | undefined = 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: Transformer<Root> = (tree) => {
visit(tree, "code", visitor);
};
return transformer;
};
export default plugin;