Skip to content

Commit 5a15683

Browse files
authored
WIP: mdx-bundler package (#2692)
1 parent 180055c commit 5a15683

File tree

12 files changed

+2014
-49
lines changed

12 files changed

+2014
-49
lines changed

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"vercel:up": "pnpm up -r \"@vercel/*\""
3838
},
3939
"resolutions": {
40+
"@types/estree": "1.0.6",
4041
"clipboardy": "4.0.0",
4142
"colorjs.io": "^0.5.2",
4243
"cookie": "0.7.0",

packages/fern-docs/mdx/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"dependencies": {
3939
"@fern-api/fdr-sdk": "workspace:*",
4040
"@fern-api/ui-core-utils": "workspace:*",
41-
"@types/estree": "^1.0.6",
41+
"@types/estree": "^1.0.7",
4242
"@types/hast": "^3.0.4",
4343
"@types/mdast": "^4.0.4",
4444
"@types/mdx": "^2.0.13",

pnpm-lock.yaml

+1,295-48
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

servers/mdx-bundler/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.vercel

servers/mdx-bundler/Dockerfile

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FROM node:20.18-alpine3.20
2+
3+
# Install dependencies
4+
RUN apk add --no-cache libc6-compat
5+
RUN npm install -g [email protected]
6+
7+
WORKDIR /app
8+
9+
# Copy the entire monorepo structure
10+
COPY . .
11+
12+
# Install dependencies
13+
RUN pnpm install
14+
15+
# Build workspace dependencies first
16+
RUN pnpm --filter @fern-docs/mdx compile
17+
18+
# Build the main package
19+
RUN pnpm --filter @fern-platform/mdx-bundler bundler:build
20+
21+
EXPOSE 8080
22+
23+
CMD ["pnpm", "--filter", "@fern-platform/mdx-bundler", "bundler:start"]

servers/mdx-bundler/package.json

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
{
2+
"name": "@fern-platform/mdx-bundler",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"files": [
7+
"dist"
8+
],
9+
"scripts": {
10+
"bundler:build": "tsc --build",
11+
"bundler:start": "tsx src/server.ts",
12+
"clean": "rm -rf ./dist && tsc --build --clean",
13+
"docker": "docker run mdx-bundler:latest pnpm bundler:build"
14+
},
15+
"dependencies": {
16+
"@fern-docs/mdx": "workspace:*",
17+
"@shikijs/rehype": "^3.2.1",
18+
"@shikijs/transformers": "^3.2.1",
19+
"@shikijs/twoslash": "^3.2.1",
20+
"@types/hast": "^3.0.4",
21+
"cors": "^2.8.5",
22+
"es-toolkit": "^1.32.0",
23+
"estree-util-to-js": "^2.0.0",
24+
"estree-walker": "^3.0.3",
25+
"express": "^4.21.2",
26+
"hast-util-properties-to-mdx-jsx-attributes": "^1.0.0",
27+
"hast-util-to-estree": "^3.1.1",
28+
"mdx-bundler": "^9.1.0",
29+
"parse-numeric-range": "^1.3.0",
30+
"ts-essentials": "^10.0.4",
31+
"yaml": "^2.3.1"
32+
},
33+
"devDependencies": {
34+
"@types/cors": "^2.8.13",
35+
"@types/express": "^4.17.13",
36+
"@types/mdx": "^2.0.13",
37+
"@types/node": "^20.17.32",
38+
"@types/react": "19.0.10",
39+
"@types/react-dom": "19.0.4",
40+
"@vercel/node": "^2.9.6",
41+
"tsx": "^4.7.1",
42+
"typescript": "^5"
43+
}
44+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// inspired by https://github.com/remcohaszing/hast-util-properties-to-mdx-jsx-attributes
2+
import { compact, flatten } from "es-toolkit/array";
3+
import { escape } from "es-toolkit/string";
4+
import { propertiesToMdxJsxAttributes } from "hast-util-properties-to-mdx-jsx-attributes";
5+
import parseNumericRange from "parse-numeric-range";
6+
7+
import {
8+
Hast,
9+
Mdast,
10+
SKIP,
11+
Unified,
12+
isMdxJsxElementHast,
13+
mdastFromMarkdown,
14+
visit,
15+
} from "@fern-docs/mdx";
16+
17+
export const rehypeCodeBlock: Unified.Plugin<[], Hast.Root> = () => {
18+
return (tree) => {
19+
visit(tree, (node, index, parent) => {
20+
if (!isMdxJsxElementHast(node)) {
21+
return;
22+
}
23+
24+
if (node.name === "CodeBlocks") {
25+
node.name = "CodeGroup";
26+
}
27+
28+
if (node.name === "CodeBlock" && node.children.length > 1) {
29+
node.name = "CodeGroup";
30+
}
31+
32+
// code groups are not currently supported for twoslash
33+
if (node.name === "CodeGroup") {
34+
for (const child of node.children) {
35+
if (
36+
child == null ||
37+
child.type !== "element" ||
38+
child.tagName !== "pre"
39+
) {
40+
return;
41+
}
42+
43+
const codeNode = child.children[0];
44+
if (
45+
codeNode == null ||
46+
codeNode.type !== "element" ||
47+
codeNode.tagName !== "code"
48+
) {
49+
return;
50+
}
51+
52+
// for now, rehypeShiki will process twoslash + shiki
53+
if (codeNode.data?.meta?.includes("twoslash")) {
54+
if (parent && index != null) {
55+
parent.children.splice(index, 1, ...node.children);
56+
return [SKIP, index];
57+
}
58+
return;
59+
}
60+
}
61+
return;
62+
}
63+
});
64+
65+
/**
66+
* Convert <pre><code>...</code></pre> to <CodeBlock>...</CodeBlock>
67+
*/
68+
visit(tree, "element", (node, index, parent) => {
69+
if (node.tagName !== "pre" || parent == null || index == null) {
70+
return;
71+
}
72+
73+
const codeNode = node.children[0];
74+
if (
75+
codeNode == null ||
76+
codeNode.type !== "element" ||
77+
codeNode.tagName !== "code"
78+
) {
79+
return;
80+
}
81+
82+
// for now, rehypeShiki will process twoslash + shiki
83+
if (codeNode.data?.meta?.includes("twoslash")) {
84+
return;
85+
}
86+
87+
const language = compact(flatten([codeNode.properties?.className]))
88+
.find(
89+
(className): className is string =>
90+
typeof className === "string" && className.startsWith("language-")
91+
)
92+
?.replace("language-", "");
93+
94+
if (language === "mermaid" && codeNode.children[0]?.type === "text") {
95+
parent?.children.splice(index, 1, {
96+
type: "mdxJsxFlowElement",
97+
name: "Mermaid",
98+
attributes: [],
99+
children: [{ type: "text", value: codeNode.children[0].value }],
100+
});
101+
return;
102+
}
103+
104+
const meta = codeNode.data?.meta ?? "";
105+
let replacement: Mdast.RootContent | undefined;
106+
107+
try {
108+
replacement = mdastFromMarkdown(
109+
`<CodeBlock ${migrateMeta(meta)} />`,
110+
"mdx"
111+
).children[0];
112+
} catch (error) {
113+
console.error(error);
114+
// if we fail to parse the meta, just wrap it in a title
115+
const props = meta.trim().length === 0 ? "" : `title="${escape(meta)}"`;
116+
replacement = mdastFromMarkdown(`<CodeBlock ${props} />`, "mdx")
117+
.children[0];
118+
}
119+
120+
if (!replacement || !isMdxJsxElementHast(replacement)) {
121+
return;
122+
}
123+
124+
if (language) {
125+
replacement.attributes.unshift({
126+
type: "mdxJsxAttribute",
127+
name: "language",
128+
value: language,
129+
});
130+
}
131+
132+
replacement.position = codeNode.position;
133+
replacement.attributes.unshift(
134+
...propertiesToMdxJsxAttributes(node.properties),
135+
...propertiesToMdxJsxAttributes(codeNode.properties)
136+
);
137+
138+
if (
139+
codeNode.children[0]?.type === "text" ||
140+
codeNode.children[0]?.type === "raw"
141+
) {
142+
const code = codeNode.children[0].value;
143+
replacement.attributes.unshift({
144+
type: "mdxJsxAttribute",
145+
name: "code",
146+
value: code,
147+
});
148+
}
149+
150+
parent.children[index] = replacement;
151+
return SKIP;
152+
});
153+
154+
/**
155+
* unravel <CodeBlock><CodeBlock>...</CodeBlock></CodeBlock> into <CodeBlock>...</CodeBlock>
156+
*/
157+
visit(tree, (node, index, parent) => {
158+
if (
159+
index == null ||
160+
parent == null ||
161+
!isMdxJsxElementHast(node) ||
162+
node.name !== "CodeBlock"
163+
) {
164+
return;
165+
}
166+
167+
const child = node.children[0];
168+
if (child && isMdxJsxElementHast(child) && child.name === "CodeBlock") {
169+
node.attributes = [...node.attributes, ...child.attributes];
170+
node.children = child.children;
171+
return [SKIP, index];
172+
}
173+
return;
174+
});
175+
};
176+
};
177+
178+
export function migrateMeta(metastring: string): string {
179+
metastring = metastring.trim();
180+
181+
if (metastring === "") {
182+
return metastring;
183+
}
184+
185+
// migrate {1-3} to {[1, 2, 3]}
186+
// but do NOT migrate {1} to {[1]}
187+
metastring = metastring.replaceAll(/\{([0-9,\s-]+)\}/g, (original, expr) => {
188+
if (expr?.includes(",") || expr?.includes("-")) {
189+
return `{[${parseNumericRange(expr ?? "")}]}`;
190+
}
191+
return original;
192+
});
193+
194+
// if matches {[, it must be preceded by a `=` otherwise prefix with `highlight=`
195+
const match = metastring.search(/\{[0-9,\s[\]-]*\}/);
196+
if (match !== -1 && metastring.slice(match + 1, match + 3) !== "...") {
197+
if (match === 0 || metastring[match - 1] !== "=") {
198+
metastring =
199+
metastring.slice(0, match) + "highlight=" + metastring.slice(match);
200+
}
201+
}
202+
203+
// migrate test=123 to test={123}
204+
metastring = metastring.replaceAll(/=([0-9]+)/g, (_original, expr) => {
205+
return `={${expr}}`;
206+
});
207+
208+
metastring = metastring.replaceAll(/=([a-zA-Z]+)/g, (original, expr) => {
209+
// don't replace booleans
210+
if (expr === "true" || expr === "false") {
211+
return original;
212+
}
213+
return `="${expr}"`;
214+
});
215+
216+
// migrate "abcd" to title="abcd"
217+
if (metastring.startsWith('"') && metastring.endsWith('"')) {
218+
return `title=${metastring}`;
219+
}
220+
221+
if (metastring.startsWith("'") && metastring.endsWith("'")) {
222+
return `title="${metastring.slice(1, -1).replace(/"/g, '\\"')}"`;
223+
}
224+
225+
function createMetaWithTitleAttribute(text: string): string {
226+
const strippedMeta = text
227+
.replaceAll(/(wordWrap)/g, "")
228+
.replaceAll(/(for="(.*?)")/g, "")
229+
.trim();
230+
if (strippedMeta.length === 0) {
231+
return text;
232+
}
233+
234+
return text.replace(
235+
strippedMeta,
236+
`title="${strippedMeta.replace(/"/g, '\\"')}"`
237+
);
238+
}
239+
240+
// migrate abcd to title="abcd"
241+
// exclude any characters wrapped in {}
242+
if (
243+
!metastring.includes("={") &&
244+
!metastring.includes('="') &&
245+
!metastring.includes("{...") &&
246+
!/\{[^}]*[a-zA-Z][^}]*\}/.test(metastring)
247+
) {
248+
return createMetaWithTitleAttribute(metastring);
249+
}
250+
251+
metastring = metastring.replaceAll(
252+
/^([^{]*?)(?=[a-zA-Z]+=)/g,
253+
(_original, text) => {
254+
if (text.trim() === "") {
255+
return "";
256+
}
257+
258+
return createMetaWithTitleAttribute(text);
259+
}
260+
);
261+
262+
// if a title hasn't been found so far, make sure it is not hidden in meta string
263+
if (!metastring.includes("title=")) {
264+
// ignore special words, anything in curly braces
265+
const parseForTitle = metastring
266+
.replaceAll(/(wordWrap)/g, "")
267+
.replaceAll(/(for="(.*?)")/g, "")
268+
.replaceAll(/([^=]+)={(.*?)}/g, "")
269+
.replaceAll(/{(.*?)}/g, "");
270+
if (parseForTitle !== "") {
271+
metastring = metastring.replace(
272+
parseForTitle,
273+
` title="${parseForTitle.trim()}" `
274+
);
275+
}
276+
}
277+
278+
return metastring;
279+
}

0 commit comments

Comments
 (0)