import React, { cloneElement } from "react";
import { htmlToJsx } from "html-to-jsx-transform";
import { Node, Parent, RootContent } from "mdast";
import remarkFrontmatter from "remark-frontmatter";
import "./footnoteHelper";
import { collapseWhiteSpace } from "collapse-white-space";
import remarkComment from "remark-comment";
import { visit } from "unist-util-visit";
import { Root } from "mdast";
import { MdxJsxFlowElement, MdxJsxTextElement } from "mdast-util-mdx-jsx";
import { remark } from "remark";
import remarkGfm from "remark-gfm";
import remarkMdx from "remark-mdx";
import { Fragment, ReactNode } from "react";
type MyRootContent = RootContent | Root;
export function mdxParse(code: string) {
const file = mdxProcessor.processSync(code);
return file.data.ast as Root;
}
declare module "mdast" {
export interface Data {
hProperties?: {
id?: string;
className?: string;
};
}
}
export type CustomTransformer = (
node: MyRootContent,
transform: (node: MyRootContent) => ReactNode
) => ReactNode | undefined;
export function SafeMdxRenderer({
components,
code = "",
mdast = null as any,
customTransformer
}: {
components?: ComponentsMap;
code?: string;
mdast?: MyRootContent;
customTransformer?: CustomTransformer;
}) {
const visitor = new MdastToJsx({
code,
mdast,
components,
customTransformer
});
const result = visitor.run();
return result;
}
export class MdastToJsx {
mdast: MyRootContent;
str: string;
jsxStr: string = "";
c: ComponentsMap;
errors: { message: string }[] = [];
customTransformer?: CustomTransformer;
constructor({
code = "",
mdast = undefined as any,
components = {} as ComponentsMap,
customTransformer
}: {
code?: string;
mdast?: MyRootContent;
components?: ComponentsMap;
customTransformer?: (
node: MyRootContent,
transform: (node: MyRootContent) => ReactNode
) => ReactNode | undefined;
}) {
this.str = code;
this.mdast = mdast || mdxParse(code);
this.customTransformer = customTransformer;
this.c = {
...Object.fromEntries(
nativeTags.map((tag) => {
return [tag, tag];
})
),
...components
};
}
mapMdastChildren(node: any) {
const res = node.children?.flatMap((child: MyRootContent) => this.mdastTransformer(child)).filter(Boolean);
if (Array.isArray(res)) {
if (!res.length) {
return null;
} else if (res.length === 1) {
return res[0];
} else {
return res.map((x, i) => (React.isValidElement(x) ? cloneElement(x, { key: i }) : x));
}
}
return res || null;
}
mapJsxChildren(node: any) {
const res = node.children?.flatMap((child: MyRootContent) => this.jsxTransformer(child)).filter(Boolean);
if (Array.isArray(res)) {
if (!res.length) {
return null;
} else if (res.length === 1) {
return res[0];
} else {
return res.map((x, i) => (React.isValidElement(x) ? cloneElement(x, { key: i }) : x));
}
}
return res || null;
}
jsxTransformer(node: MyRootContent): ReactNode {
if (!node) {
return [];
}
switch (node.type) {
case "mdxJsxTextElement":
case "mdxJsxFlowElement": {
if (!node.name) {
return [];
}
const Component = accessWithDot(this.c, node.name);
if (!Component) {
this.errors.push({
message: `Unsupported jsx component ${node.name}`
});
return null;
}
let attrsList = getJsxAttrs(node, (err) => {
this.errors.push(err);
});
let attrs = Object.fromEntries(attrsList);
function cssStringToObject(css: string): Record {
const styleObj: Record = {};
css.split(";").forEach((rule) => {
const [key, val] = rule.trim().split(":");
if (key && val) {
styleObj[key.trim()] = val.trim();
}
});
return styleObj;
}
const SafeComponent: React.FC = ({ style, ...props }) => {
const safeStyle = React.useMemo(() => {
if (typeof style === "string") {
return cssStringToObject(style);
}
return style || {};
}, [style]);
return ;
};
return {this.mapJsxChildren(node)};
}
default: {
return this.mdastTransformer(node);
}
}
}
run() {
const res = this.mdastTransformer(this.mdast) as ReactNode;
if (Array.isArray(res) && res.length === 1) {
return res[0];
}
return res;
}
mdastTransformer(node: MyRootContent): ReactNode {
if (!node) {
return [];
}
// Check for custom transformer first, giving it higher priority
if (this.customTransformer) {
const customResult = this.customTransformer(node, this.mdastTransformer.bind(this));
if (customResult !== undefined) {
return customResult;
}
}
switch (node.type) {
case "mdxjsEsm": {
// const start = node.position?.start?.offset;
// const end = node.position?.end?.offset;
// let text = this.str.slice(start, end);
return [];
}
case "mdxJsxTextElement":
case "mdxJsxFlowElement": {
const start = node.position?.start?.offset;
const end = node.position?.end?.offset;
const text = this.str.slice(start, end);
// XSS protection
if (node.name === "script") {
return [];
}
try {
this.jsxStr = text;
const result = this.jsxTransformer(node);
if (Array.isArray(result)) {
console.log(`Unexpected array result`);
} else if (result) {
return result;
}
} finally {
this.jsxStr = "";
}
return [];
}
case "mdxFlowExpression":
case "mdxTextExpression": {
if (!node.value) {
return [];
}
return [];
}
case "yaml": {
if (!node.value) {
return [];
}
return [];
}
case "heading": {
const level = node.depth;
const Tag = this.c[`h${level}`] ?? `h${level}`;
return {this.mapMdastChildren(node)};
}
case "paragraph": {
return {this.mapMdastChildren(node)};
}
case "blockquote": {
return {this.mapMdastChildren(node)};
}
case "thematicBreak": {
return ;
}
case "code": {
if (!node.value) {
return [];
}
const language = node.lang || "";
const code = node.value;
const codeBlock = (className?: string) => (
{code}
);
if (language) {
if (supportedLanguagesSet.has(language as (typeof supportedLanguages)[number])) {
return codeBlock(`language-${language}`);
} else {
this.errors.push({
message: `Unsupported language ${language}`
});
return codeBlock();
}
}
return codeBlock();
}
case "list": {
if (node.ordered) {
return (
{this.mapMdastChildren(node)}
);
}
return {this.mapMdastChildren(node)};
}
case "listItem": {
// https://github.com/syntax-tree/mdast-util-gfm-task-list-item#syntax-tree
if (node?.checked != null) {
return (
{this.mapMdastChildren(node)}
);
}
return {this.mapMdastChildren(node)};
}
case "text": {
if (!node.value) {
return [];
}
return node.value;
}
case "image": {
const src = node.url || "";
const alt = node.alt || "";
const title = node.title || "";
return ;
}
case "link": {
const href = node.url || "";
const title = node.title || "";
return (
{this.mapMdastChildren(node)}
);
}
case "strong": {
return {this.mapMdastChildren(node)};
}
case "emphasis": {
return {this.mapMdastChildren(node)};
}
case "delete": {
return {this.mapMdastChildren(node)};
}
case "inlineCode": {
if (!node.value) {
return [];
}
return {node.value};
}
case "break": {
return ;
}
case "root": {
return {this.mapMdastChildren(node)};
}
case "table": {
const [head, ...body] = React.Children.toArray(this.mapMdastChildren(node));
return (
{head && {head}}
{!!body?.length && {body}}
);
}
case "tableRow": {
return (
{this.mapMdastChildren(node)}
);
}
case "tableCell": {
let content = this.mapMdastChildren(node);
return (
{content}
);
}
case "definition": {
return [];
}
case "linkReference": {
let href = "";
mdastBfs(this.mdast, (child: any) => {
if (child.type === "definition" && child.identifier === node.identifier) {
href = child.url;
}
});
return (
{this.mapMdastChildren(node)}
);
}
case "footnoteReference": {
return (
{node.label}
);
}
case "footnoteDefinition": {
let content = this.mapMdastChildren(node);
return (
{content}
);
}
case "html": {
const start = node.position?.start?.offset;
const end = node.position?.end?.offset;
const text = this.str.slice(start, end);
if (!text) {
return [];
}
const jsx = htmlToJsx(text);
try {
this.jsxStr = jsx;
const result = this.jsxTransformer(node);
if (Array.isArray(result)) {
console.log(`Unexpected array result`);
} else if (result) {
return result;
}
} finally {
this.jsxStr = "";
}
return [];
}
case "imageReference": {
return [];
}
default: {
mdastBfs(node, (node) => {
delete node.position;
});
throw new Error(`cannot convert node` + JSON.stringify(node, null, 2));
}
}
}
}
export function getJsxAttrs(
node: MdxJsxFlowElement | MdxJsxTextElement,
onError: (err: { message: string }) => void = console.error
) {
let attrsList = node.attributes
.map((attr) => {
if (attr.type === "mdxJsxExpressionAttribute") {
onError({
message: `Expressions in jsx props are not supported (${attr.value
.replace(/\n+/g, " ")
.replace(/ +/g, " ")})`
});
return;
}
if (attr.type !== "mdxJsxAttribute") {
throw new Error(`non mdxJsxAttribute is not supported: ${attr}`);
}
const v = attr.value;
if (typeof v === "string" || typeof v === "number") {
return [attr.name, v];
}
if (v === null) {
return [attr.name, true];
}
if (v?.type === "mdxJsxAttributeValueExpression") {
if (v.value === "true") {
return [attr.name, true];
}
if (v.value === "false") {
return [attr.name, false];
}
if (v.value === "null") {
return [attr.name, null];
}
if (v.value === "undefined") {
return [attr.name, undefined];
}
let quote = ['"', "'", "`"].find((q) => v.value.startsWith(q) && v.value.endsWith(q));
if (quote) {
let value = v.value;
if (quote !== '"') {
value = v.value.replace(new RegExp(quote, "g"), '"');
}
return [attr.name, JSON.parse(value)];
}
const number = Number(v.value);
if (!isNaN(number)) {
return [attr.name, number];
}
const parsedJson = safeJsonParse(v.value);
if (parsedJson) {
return [attr.name, parsedJson];
}
onError({
message: `Expressions in jsx props are not supported (${attr.name}={${v.value}})`
});
} else {
console.log("unhandled attr", { attr }, attr.type);
}
return;
})
.filter(isTruthy) as [string, any][];
return attrsList;
}
function isTruthy(val: T | undefined | null | false): val is T {
return Boolean(val);
}
function accessWithDot(obj: any, path: string) {
return path
.split(".")
.map((x) => x.trim())
.filter(Boolean)
.reduce((o, i) => o[i], obj);
}
export function mdastBfs(node: Parent | Node, cb?: (node: Node | Parent) => any) {
const queue = [node];
const result: any[] = [];
while (queue.length) {
const node = queue.shift();
let r = cb && node ? cb(node) : node;
if (Array.isArray(r)) {
queue.push(...r);
} else if (r) {
result.push(r);
}
if (node && "children" in node && node.children) {
queue.push(...(node.children as any));
}
}
return result;
}
function safeJsonParse(str: string) {
try {
return JSON.parse(str);
} catch (err) {
return null;
}
}
const nativeTags = [
"blockquote",
"strong",
"em",
"del",
"hr",
"a",
"b",
"br",
"button",
"div",
"form",
"h1",
"h2",
"h3",
"h4",
"head",
"iframe",
"img",
"input",
"label",
"li",
"link",
"ol",
"p",
"path",
"picture",
"script",
"section",
"source",
"span",
"sub",
"sup",
"svg",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tr",
"ul",
"video",
"code",
"pre",
"figure",
"canvas",
"details",
"dl",
"dt",
"dd",
"fieldset",
"footer",
"header",
"legend",
"main",
"mark",
"nav",
"progress",
"summary",
"time"
] as const;
const supportedLanguages = [
"abap",
"abnf",
"actionscript",
"ada",
"agda",
"al",
"antlr4",
"apacheconf",
"apex",
"apl",
"applescript",
"aql",
"arduino",
"arff",
"asciidoc",
"asm6502",
"asmatmel",
"aspnet",
"autohotkey",
"autoit",
"avisynth",
"avro-idl",
"bash",
"basic",
"batch",
"bbcode",
"bicep",
"birb",
"bison",
"bnf",
"brainfuck",
"brightscript",
"bro",
"bsl",
"c",
"cfscript",
"chaiscript",
"cil",
"clike",
"clojure",
"cmake",
"cobol",
"coffeescript",
"concurnas",
"coq",
"cpp",
"crystal",
"csharp",
"cshtml",
"csp",
"css-extras",
"css",
"csv",
"cypher",
"d",
"dart",
"dataweave",
"dax",
"dhall",
"diff",
"django",
"dns-zone-file",
"docker",
"dot",
"ebnf",
"editorconfig",
"eiffel",
"ejs",
"elixir",
"elm",
"erb",
"erlang",
"etlua",
"excel-formula",
"factor",
"false",
"firestore-security-rules",
"flow",
"fortran",
"fsharp",
"ftl",
"gap",
"gcode",
"gdscript",
"gedcom",
"gherkin",
"git",
"glsl",
"gml",
"gn",
"go-module",
"go",
"graphql",
"groovy",
"haml",
"handlebars",
"haskell",
"haxe",
"hcl",
"hlsl",
"hoon",
"hpkp",
"hsts",
"http",
"ichigojam",
"icon",
"icu-message-format",
"idris",
"iecst",
"ignore",
"inform7",
"ini",
"io",
"j",
"java",
"javadoc",
"javadoclike",
"javascript",
"javastacktrace",
"jexl",
"jolie",
"jq",
"js-extras",
"js-templates",
"jsdoc",
"json",
"json5",
"jsonp",
"jsstacktrace",
"jsx",
"julia",
"keepalived",
"keyman",
"kotlin",
"kumir",
"kusto",
"latex",
"latte",
"less",
"lilypond",
"liquid",
"lisp",
"livescript",
"llvm",
"log",
"lolcode",
"lua",
"magma",
"makefile",
"markdown",
"markup-templating",
"markup",
"matlab",
"maxscript",
"mel",
"mermaid",
"mizar",
"mongodb",
"monkey",
"moonscript",
"n1ql",
"n4js",
"nand2tetris-hdl",
"naniscript",
"nasm",
"neon",
"nevod",
"nginx",
"nim",
"nix",
"nsis",
"objectivec",
"ocaml",
"opencl",
"openqasm",
"oz",
"parigp",
"parser",
"pascal",
"pascaligo",
"pcaxis",
"peoplecode",
"perl",
"php-extras",
"php",
"phpdoc",
"plsql",
"powerquery",
"powershell",
"processing",
"prolog",
"promql",
"properties",
"protobuf",
"psl",
"pug",
"puppet",
"pure",
"purebasic",
"purescript",
"python",
"q",
"qml",
"qore",
"qsharp",
"r",
"racket",
"reason",
"regex",
"rego",
"renpy",
"rest",
"rip",
"roboconf",
"robotframework",
"ruby",
"rust",
"sas",
"sass",
"scala",
"scheme",
"scss",
"shell-session",
"smali",
"smalltalk",
"smarty",
"sml",
"solidity",
"solution-file",
"soy",
"sparql",
"splunk-spl",
"sqf",
"sql",
"squirrel",
"stan",
"stylus",
"swift",
"systemd",
"t4-cs",
"t4-templating",
"t4-vb",
"tap",
"tcl",
"textile",
"toml",
"tremor",
"tsx",
"tt2",
"turtle",
"twig",
"typescript",
"typoscript",
"unrealscript",
"uorazor",
"uri",
"v",
"vala",
"vbnet",
"velocity",
"verilog",
"vhdl",
"vim",
"visual-basic",
"warpscript",
"wasm",
"web-idl",
"wiki",
"wolfram",
"wren",
"xeora",
"xml-doc",
"xojo",
"xquery",
"yaml",
"yang",
"zig"
] as const;
const supportedLanguagesSet = new Set(supportedLanguages);
type ComponentsMap = { [k in (typeof nativeTags)[number]]?: any } & {
[key: string]: any;
};
/**
* https://github.com/mdx-js/mdx/blob/b3351fadcb6f78833a72757b7135dcfb8ab646fe/packages/mdx/lib/plugin/remark-mark-and-unravel.js
* A tiny plugin that unravels `x
` but also
* `
` (so it has no knowledge of "HTML").
*
* It also marks JSX as being explicitly JSX, so when a user passes a `h1`
* component, it is used for `# heading` but not for `heading
`.
*
*/
export function remarkMarkAndUnravel() {
return function (tree: Root) {
visit(tree, function (node, index, parent) {
let offset = -1;
let all = true;
let oneOrMore = false;
if (parent && typeof index === "number" && node.type === "paragraph") {
const children = node.children;
while (++offset < children.length) {
const child = children[offset];
if (child.type === "mdxJsxTextElement" || child.type === "mdxTextExpression") {
oneOrMore = true;
} else if (
child.type === "text" &&
collapseWhiteSpace(child.value, {
style: "html",
trim: true
}) === ""
) {
// Empty.
} else {
all = false;
break;
}
}
if (all && oneOrMore) {
offset = -1;
const newChildren: RootContent[] = [];
while (++offset < children.length) {
const child = children[offset];
if (child.type === "mdxJsxTextElement") {
// @ts-expect-error: mutate because it is faster; content model is fine.
child.type = "mdxJsxFlowElement";
}
if (child.type === "mdxTextExpression") {
// @ts-expect-error: mutate because it is faster; content model is fine.
child.type = "mdxFlowExpression";
}
if (child.type === "text" && /^[\t\r\n ]+$/.test(String(child.value))) {
// Empty.
} else {
newChildren.push(child);
}
}
parent.children.splice(index, 1, ...newChildren);
return index;
}
}
});
};
}
const mdxProcessor = remark()
.use(remarkMdx)
.use(remarkComment)
.use(remarkFrontmatter, ["yaml", "toml"])
.use(remarkGfm)
.use(remarkMarkAndUnravel)
.use(() => {
return (tree, file) => {
file.data.ast = tree;
};
});