diff --git a/packages/fern-docs/mdx/src/plugins/__test__/__snapshots__/rehype-expression-to-md.test.ts.snap b/packages/fern-docs/mdx/src/plugins/__test__/__snapshots__/rehype-expression-to-md.test.ts.snap new file mode 100644 index 0000000000..0ea5dee981 --- /dev/null +++ b/packages/fern-docs/mdx/src/plugins/__test__/__snapshots__/rehype-expression-to-md.test.ts.snap @@ -0,0 +1,1023 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`rehype-expression-to-md > should convert jsx body of an expression to md 1`] = ` +{ + "children": [ + { + "data": { + "estree": { + "body": [ + { + "end": 40, + "expression": { + "children": [ + { + "children": [ + { + "children": [ + { + "children": [ + { + "end": 6, + "expression": { + "end": 6, + "loc": { + "end": { + "column": 6, + "line": 1, + }, + "start": { + "column": 1, + "line": 1, + }, + }, + "range": [ + 1, + 6, + ], + "start": 1, + "type": "Literal", + "value": "Hello", + }, + "loc": { + "end": { + "column": 6, + "line": 1, + }, + "start": { + "column": 1, + "line": 1, + }, + }, + "range": [ + 1, + 6, + ], + "start": 1, + "type": "JSXExpressionContainer", + }, + ], + "closingElement": { + "name": { + "name": "a", + "type": "JSXIdentifier", + }, + "type": "JSXClosingElement", + }, + "end": 28, + "loc": { + "end": { + "column": 28, + "line": 1, + }, + "start": { + "column": 0, + "line": 1, + }, + }, + "openingElement": { + "attributes": [ + { + "name": { + "name": "href", + "type": "JSXIdentifier", + }, + "type": "JSXAttribute", + "value": { + "type": "Literal", + "value": "https://example.com", + }, + }, + ], + "name": { + "name": "a", + "type": "JSXIdentifier", + }, + "selfClosing": false, + "type": "JSXOpeningElement", + }, + "range": [ + 0, + 28, + ], + "start": 0, + "type": "JSXElement", + }, + ], + "closingElement": { + "name": { + "name": "p", + "type": "JSXIdentifier", + }, + "type": "JSXClosingElement", + }, + "end": 28, + "loc": { + "end": { + "column": 28, + "line": 1, + }, + "start": { + "column": 0, + "line": 1, + }, + }, + "openingElement": { + "attributes": [], + "name": { + "name": "p", + "type": "JSXIdentifier", + }, + "selfClosing": false, + "type": "JSXOpeningElement", + }, + "range": [ + 0, + 28, + ], + "start": 0, + "type": "JSXElement", + }, + ], + "closingFragment": { + "type": "JSXClosingFragment", + }, + "end": 28, + "loc": { + "end": { + "column": 28, + "line": 1, + }, + "start": { + "column": 0, + "line": 1, + }, + }, + "openingFragment": { + "type": "JSXOpeningFragment", + }, + "range": [ + 0, + 28, + ], + "start": 0, + "type": "JSXFragment", + }, + ], + "closingElement": { + "end": 40, + "loc": { + "end": { + "column": 40, + "line": 1, + "offset": 40, + }, + "start": { + "column": 34, + "line": 1, + "offset": 34, + }, + }, + "name": { + "end": 39, + "loc": { + "end": { + "column": 39, + "line": 1, + "offset": 39, + }, + "start": { + "column": 36, + "line": 1, + "offset": 36, + }, + }, + "name": "div", + "range": [ + 36, + 39, + ], + "start": 36, + "type": "JSXIdentifier", + }, + "range": [ + 34, + 40, + ], + "start": 34, + "type": "JSXClosingElement", + }, + "data": { + "_mdxExplicitJsx": true, + }, + "end": 40, + "loc": { + "end": { + "column": 40, + "line": 1, + "offset": 40, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "openingElement": { + "attributes": [], + "end": 6, + "loc": { + "end": { + "column": 6, + "line": 1, + "offset": 6, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "name": { + "end": 5, + "loc": { + "end": { + "column": 5, + "line": 1, + "offset": 5, + }, + "start": { + "column": 2, + "line": 1, + "offset": 2, + }, + }, + "name": "div", + "range": [ + 2, + 5, + ], + "start": 2, + "type": "JSXIdentifier", + }, + "range": [ + 1, + 6, + ], + "selfClosing": false, + "start": 1, + "type": "JSXOpeningElement", + }, + "range": [ + 1, + 40, + ], + "start": 1, + "type": "JSXElement", + }, + "loc": { + "end": { + "column": 40, + "line": 1, + "offset": 40, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "range": [ + 1, + 40, + ], + "start": 1, + "type": "ExpressionStatement", + }, + ], + "comments": [], + "end": 40, + "loc": { + "end": { + "column": 40, + "line": 1, + "offset": 40, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "range": [ + 1, + 40, + ], + "sourceType": "module", + "start": 1, + "type": "Program", + }, + }, + "position": { + "end": { + "column": 42, + "line": 1, + "offset": 41, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "mdxFlowExpression", + "value": "
[Hello](https://example.com)
", + }, + ], + "position": { + "end": { + "column": 42, + "line": 1, + "offset": 41, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "root", +} +`; + +exports[`rehype-expression-to-md > should convert markdown inside of attributes into jsx 1`] = ` +{ + "children": [ + { + "data": { + "estree": { + "body": [ + { + "end": 62, + "expression": { + "children": [ + { + "children": [], + "closingElement": null, + "data": { + "_mdxExplicitJsx": true, + }, + "end": 54, + "loc": { + "end": { + "column": 54, + "line": 1, + "offset": 54, + }, + "start": { + "column": 47, + "line": 1, + "offset": 47, + }, + }, + "openingElement": { + "attributes": [], + "end": 54, + "loc": { + "end": { + "column": 54, + "line": 1, + "offset": 54, + }, + "start": { + "column": 47, + "line": 1, + "offset": 47, + }, + }, + "name": { + "end": 51, + "loc": { + "end": { + "column": 51, + "line": 1, + "offset": 51, + }, + "start": { + "column": 48, + "line": 1, + "offset": 48, + }, + }, + "name": "img", + "range": [ + 48, + 51, + ], + "start": 48, + "type": "JSXIdentifier", + }, + "range": [ + 47, + 54, + ], + "selfClosing": true, + "start": 47, + "type": "JSXOpeningElement", + }, + "range": [ + 47, + 54, + ], + "start": 47, + "type": "JSXElement", + }, + ], + "closingElement": { + "end": 62, + "loc": { + "end": { + "column": 62, + "line": 1, + "offset": 62, + }, + "start": { + "column": 54, + "line": 1, + "offset": 54, + }, + }, + "name": { + "end": 61, + "loc": { + "end": { + "column": 61, + "line": 1, + "offset": 61, + }, + "start": { + "column": 56, + "line": 1, + "offset": 56, + }, + }, + "name": "Frame", + "range": [ + 56, + 61, + ], + "start": 56, + "type": "JSXIdentifier", + }, + "range": [ + 54, + 62, + ], + "start": 54, + "type": "JSXClosingElement", + }, + "data": { + "_mdxExplicitJsx": true, + }, + "end": 62, + "loc": { + "end": { + "column": 62, + "line": 1, + "offset": 62, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "openingElement": { + "attributes": [ + { + "end": 46, + "loc": { + "end": { + "column": 46, + "line": 1, + "offset": 46, + }, + "start": { + "column": 8, + "line": 1, + "offset": 8, + }, + }, + "name": { + "end": 15, + "loc": { + "end": { + "column": 15, + "line": 1, + "offset": 15, + }, + "start": { + "column": 8, + "line": 1, + "offset": 8, + }, + }, + "name": "caption", + "range": [ + 8, + 15, + ], + "start": 8, + "type": "JSXIdentifier", + }, + "range": [ + 8, + 46, + ], + "start": 8, + "type": "JSXAttribute", + "value": { + "end": 46, + "loc": { + "end": { + "column": 46, + "line": 1, + "offset": 46, + }, + "start": { + "column": 16, + "line": 1, + "offset": 16, + }, + }, + "range": [ + 16, + 46, + ], + "raw": ""[Hello](https://example.com)"", + "start": 16, + "type": "Literal", + "value": "[Hello](https://example.com)", + }, + }, + ], + "end": 47, + "loc": { + "end": { + "column": 47, + "line": 1, + "offset": 47, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "name": { + "end": 7, + "loc": { + "end": { + "column": 7, + "line": 1, + "offset": 7, + }, + "start": { + "column": 2, + "line": 1, + "offset": 2, + }, + }, + "name": "Frame", + "range": [ + 2, + 7, + ], + "start": 2, + "type": "JSXIdentifier", + }, + "range": [ + 1, + 47, + ], + "selfClosing": false, + "start": 1, + "type": "JSXOpeningElement", + }, + "range": [ + 1, + 62, + ], + "start": 1, + "type": "JSXElement", + }, + "loc": { + "end": { + "column": 62, + "line": 1, + "offset": 62, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "range": [ + 1, + 62, + ], + "start": 1, + "type": "ExpressionStatement", + }, + ], + "comments": [], + "end": 62, + "loc": { + "end": { + "column": 62, + "line": 1, + "offset": 62, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "range": [ + 1, + 62, + ], + "sourceType": "module", + "start": 1, + "type": "Program", + }, + }, + "position": { + "end": { + "column": 64, + "line": 1, + "offset": 63, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "mdxFlowExpression", + "value": "", + }, + ], + "position": { + "end": { + "column": 64, + "line": 1, + "offset": 63, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "root", +} +`; + +exports[`rehype-expression-to-md > should does not convert markdown inside of attributes if the element is not in the allowlist 1`] = ` +{ + "children": [ + { + "data": { + "estree": { + "body": [ + { + "end": 62, + "expression": { + "children": [ + { + "children": [], + "closingElement": null, + "data": { + "_mdxExplicitJsx": true, + }, + "end": 54, + "loc": { + "end": { + "column": 54, + "line": 1, + "offset": 54, + }, + "start": { + "column": 47, + "line": 1, + "offset": 47, + }, + }, + "openingElement": { + "attributes": [], + "end": 54, + "loc": { + "end": { + "column": 54, + "line": 1, + "offset": 54, + }, + "start": { + "column": 47, + "line": 1, + "offset": 47, + }, + }, + "name": { + "end": 51, + "loc": { + "end": { + "column": 51, + "line": 1, + "offset": 51, + }, + "start": { + "column": 48, + "line": 1, + "offset": 48, + }, + }, + "name": "img", + "range": [ + 48, + 51, + ], + "start": 48, + "type": "JSXIdentifier", + }, + "range": [ + 47, + 54, + ], + "selfClosing": true, + "start": 47, + "type": "JSXOpeningElement", + }, + "range": [ + 47, + 54, + ], + "start": 47, + "type": "JSXElement", + }, + ], + "closingElement": { + "end": 62, + "loc": { + "end": { + "column": 62, + "line": 1, + "offset": 62, + }, + "start": { + "column": 54, + "line": 1, + "offset": 54, + }, + }, + "name": { + "end": 61, + "loc": { + "end": { + "column": 61, + "line": 1, + "offset": 61, + }, + "start": { + "column": 56, + "line": 1, + "offset": 56, + }, + }, + "name": "Frame", + "range": [ + 56, + 61, + ], + "start": 56, + "type": "JSXIdentifier", + }, + "range": [ + 54, + 62, + ], + "start": 54, + "type": "JSXClosingElement", + }, + "data": { + "_mdxExplicitJsx": true, + }, + "end": 62, + "loc": { + "end": { + "column": 62, + "line": 1, + "offset": 62, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "openingElement": { + "attributes": [ + { + "end": 46, + "loc": { + "end": { + "column": 46, + "line": 1, + "offset": 46, + }, + "start": { + "column": 8, + "line": 1, + "offset": 8, + }, + }, + "name": { + "end": 15, + "loc": { + "end": { + "column": 15, + "line": 1, + "offset": 15, + }, + "start": { + "column": 8, + "line": 1, + "offset": 8, + }, + }, + "name": "caption", + "range": [ + 8, + 15, + ], + "start": 8, + "type": "JSXIdentifier", + }, + "range": [ + 8, + 46, + ], + "start": 8, + "type": "JSXAttribute", + "value": { + "end": 46, + "loc": { + "end": { + "column": 46, + "line": 1, + "offset": 46, + }, + "start": { + "column": 16, + "line": 1, + "offset": 16, + }, + }, + "range": [ + 16, + 46, + ], + "raw": ""[Hello](https://example.com)"", + "start": 16, + "type": "Literal", + "value": "[Hello](https://example.com)", + }, + }, + ], + "end": 47, + "loc": { + "end": { + "column": 47, + "line": 1, + "offset": 47, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "name": { + "end": 7, + "loc": { + "end": { + "column": 7, + "line": 1, + "offset": 7, + }, + "start": { + "column": 2, + "line": 1, + "offset": 2, + }, + }, + "name": "Frame", + "range": [ + 2, + 7, + ], + "start": 2, + "type": "JSXIdentifier", + }, + "range": [ + 1, + 47, + ], + "selfClosing": false, + "start": 1, + "type": "JSXOpeningElement", + }, + "range": [ + 1, + 62, + ], + "start": 1, + "type": "JSXElement", + }, + "loc": { + "end": { + "column": 62, + "line": 1, + "offset": 62, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "range": [ + 1, + 62, + ], + "start": 1, + "type": "ExpressionStatement", + }, + ], + "comments": [], + "end": 62, + "loc": { + "end": { + "column": 62, + "line": 1, + "offset": 62, + }, + "start": { + "column": 1, + "line": 1, + "offset": 1, + }, + }, + "range": [ + 1, + 62, + ], + "sourceType": "module", + "start": 1, + "type": "Program", + }, + }, + "position": { + "end": { + "column": 64, + "line": 1, + "offset": 63, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "mdxFlowExpression", + "value": "", + }, + ], + "position": { + "end": { + "column": 64, + "line": 1, + "offset": 63, + }, + "start": { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "root", +} +`; diff --git a/packages/fern-docs/mdx/src/plugins/__test__/rehype-expression-to-md.test.ts b/packages/fern-docs/mdx/src/plugins/__test__/rehype-expression-to-md.test.ts new file mode 100644 index 0000000000..5a3d909b3e --- /dev/null +++ b/packages/fern-docs/mdx/src/plugins/__test__/rehype-expression-to-md.test.ts @@ -0,0 +1,30 @@ +import { toTree } from "../../parse"; +import { rehypeExpressionToMd } from "../rehype-expression-to-md"; + +describe("rehype-expression-to-md", () => { + it("should convert jsx body of an expression to md", () => { + const hast = toTree("{
[Hello](https://example.com)
}").hast; + (rehypeExpressionToMd as any)()(hast); + expect(hast).toMatchSnapshot(); + }); + + it("should convert markdown inside of attributes into jsx", () => { + const hast = toTree( + '{}' + ).hast; + (rehypeExpressionToMd as any)({ + mdxJsxElementAllowlist: { + Frame: ["caption"], + }, + })(hast); + expect(hast).toMatchSnapshot(); + }); + + it("should does not convert markdown inside of attributes if the element is not in the allowlist", () => { + const hast = toTree( + '{}' + ).hast; + (rehypeExpressionToMd as any)({})(hast); + expect(hast).toMatchSnapshot(); + }); +}); diff --git a/packages/fern-docs/mdx/src/plugins/index.ts b/packages/fern-docs/mdx/src/plugins/index.ts index c200081296..f544fd8da1 100644 --- a/packages/fern-docs/mdx/src/plugins/index.ts +++ b/packages/fern-docs/mdx/src/plugins/index.ts @@ -1,4 +1,5 @@ export * from "./rehype-acorn-error-boundary"; +export * from "./rehype-expression-to-md"; export * from "./rehype-mdx-class-style"; export * from "./rehype-squeeze-paragraphs"; export * from "./remark-inject-esm"; diff --git a/packages/fern-docs/mdx/src/plugins/rehype-expression-to-md.ts b/packages/fern-docs/mdx/src/plugins/rehype-expression-to-md.ts new file mode 100644 index 0000000000..0823d4c8b9 --- /dev/null +++ b/packages/fern-docs/mdx/src/plugins/rehype-expression-to-md.ts @@ -0,0 +1,109 @@ +import type { Program } from "estree"; +import { walk } from "estree-walker"; +import { toEstree } from "hast-util-to-estree"; +import { toHast } from "mdast-util-to-hast"; +import type { Plugin } from "unified"; +import { visit } from "unist-util-visit"; +import { mdastFromMarkdown } from "../mdast-utils/mdast-from-markdown"; +import { + isMdxExpression, + isMdxJsxAttribute, + isMdxJsxElementHast, +} from "../mdx-utils"; +import type { Hast, Mdast } from "../types"; + +export const rehypeExpressionToMd: Plugin< + [{ mdxJsxElementAllowlist?: Record }?], + Hast.Root +> = + ({ mdxJsxElementAllowlist = {} } = {}) => + (ast) => { + visit(ast, (node) => { + /** + * Example: + * {
[Hello](https://example.com)
} -> {
Hello
} + */ + if (isMdxExpression(node)) { + const estree = node.data?.estree; + if (!estree) { + return; + } + replaceJsxTextToMarkdown(estree); + } + + /** + * Example: + * -> Hello} /> + */ + if (isMdxJsxElementHast(node)) { + const allowlist = mdxJsxElementAllowlist[node.name ?? "Fragment"]; + if (allowlist == null) { + return; + } + node.attributes.forEach((attribute) => { + if ( + isMdxJsxAttribute(attribute) && + allowlist.includes(attribute.name) && + typeof attribute.value === "string" + ) { + const expression = mdToEstree(attribute.value); + if (expression) { + attribute.value = { + type: "mdxJsxAttributeValueExpression", + value: attribute.value, + data: { estree: expression }, + }; + } + } + }); + } + }); + }; + +function replaceJsxTextToMarkdown(estree: Program) { + walk(estree, { + enter(node) { + if (node.type === "JSXText") { + const replacement = mdToEstree(node.value); + if (replacement) { + const expression = getExpression(replacement); + if (expression) { + this.replace(expression); + } + } + } + }, + }); +} + +function mdToEstree(string: string) { + const mdast = mdastFromMarkdown(string, "md"); // this plugin only applies to markdown, not mdx + + // only replace if the string actually contains markdown + const children = withoutParagraphs(mdast); + if ( + children.length === 0 || + children.every((child) => child.type === "text") + ) { + return; + } + + const hast = toHast(mdast); + const estree = toEstree(hast); + return estree; +} + +function getExpression(estree: Program) { + return estree.body[0]?.type === "ExpressionStatement" + ? estree.body[0].expression + : null; +} + +function withoutParagraphs(mdast: Mdast.Root): Mdast.PhrasingContent[] { + return mdast.children.flatMap((child) => { + if (child.type === "paragraph") { + return child.children; + } + return []; + }); +} diff --git a/packages/fern-docs/ui/src/mdx/bundlers/mdx-bundler.ts b/packages/fern-docs/ui/src/mdx/bundlers/mdx-bundler.ts index 1f46fbc19f..24a01dcbbb 100644 --- a/packages/fern-docs/ui/src/mdx/bundlers/mdx-bundler.ts +++ b/packages/fern-docs/ui/src/mdx/bundlers/mdx-bundler.ts @@ -8,6 +8,7 @@ import { } from "@fern-docs/mdx"; import { rehypeAcornErrorBoundary, + rehypeExpressionToMd, rehypeMdxClassStyle, rehypeSqueezeParagraphs, remarkInjectEsm, @@ -139,6 +140,19 @@ async function serializeMdxImpl( const rehypePlugins: PluggableList = [ rehypeSqueezeParagraphs, + [ + rehypeExpressionToMd, + { + mdxJsxElementAllowlist: { + Frame: ["caption"], + Tab: ["title"], + Card: ["title", "icon"], + Callout: ["title", "icon"], + Step: ["title"], + Accordion: ["title"], + }, + }, + ], rehypeMdxClassStyle, [rehypeFiles, { replaceSrc }], rehypeAcornErrorBoundary, diff --git a/packages/fern-docs/ui/src/mdx/bundlers/next-mdx-remote.ts b/packages/fern-docs/ui/src/mdx/bundlers/next-mdx-remote.ts index 30ac9e0449..69d5cc7b3d 100644 --- a/packages/fern-docs/ui/src/mdx/bundlers/next-mdx-remote.ts +++ b/packages/fern-docs/ui/src/mdx/bundlers/next-mdx-remote.ts @@ -9,6 +9,7 @@ import { } from "@fern-docs/mdx"; import { rehypeAcornErrorBoundary, + rehypeExpressionToMd, rehypeMdxClassStyle, rehypeSqueezeParagraphs, remarkSanitizeAcorn, @@ -58,6 +59,19 @@ function withDefaultMdxOptions( const rehypePlugins: PluggableList = [ rehypeSqueezeParagraphs, + [ + rehypeExpressionToMd, + { + mdxJsxElementAllowlist: { + Frame: ["caption"], + Tab: ["title"], + Card: ["title", "icon"], + Callout: ["title", "icon"], + Step: ["title"], + Accordion: ["title"], + }, + }, + ], rehypeMdxClassStyle, [rehypeFiles, { replaceSrc }], rehypeAcornErrorBoundary,