Skip to content

Commit 9e8f85f

Browse files
maximilianfalcoeaglethrostrafegoldberg
authored
feat(mdxish): add new MDXish engine (#1243)
[![PR App][icn]][demo] | Fix RM-XYZ :-------------------:|:----------: > [!WARNING] > As of `5 Dec 2025` (keeping this to track timeline) SSR for `mdxish` has been disabled. All contents that are rendered by `mdxish` will be done client-side. For more context, refer to this [thread](#1243 (comment)) ## 🧰 Changes #### Context This PR exports 2 new libraries which provides a new way to render mixed Markdown + MDX content in our application. This allows customers to flexibly embed MDX inside Markdown without relying on the strict MDX renderer or needing to migrate everything to MDX (which currently causes many errors and requires hours of cleanup) > [!IMPORTANT] > With the addition of the new libraries, we unfortunately have exceeded the maximum bundle size allowed. Specifically the current bundle size is `762KB` and the limit was `750KB`, this has been increased to `775KB` #### Changes 1. `mdxish.ts` - Engine to convert a Markdown + MDX string into an HTML AST. - Based on Greg’s prototype and uses Unified plugins. - Handles MDX by preprocessing its syntax. - Additional logic: - Reuses existing transformers (e.g., callouts). - Adjusts MDX nodes when spacing breaks the AST. - Custom component handling: - Recursively parses inner content. - Renames nodes to PascalCase. - Includes heuristics to determine whether a tag is a real component vs. an HTML tag. 2. `renderMdxish.tsx` - Converts the HTML AST into React JSX components - Mimics the existing `run.tsx` behaviour used in production, returns an RMDXModule which contains the content react component, and the table of contents > _We also expose another library called `mix` but this isnt actually used for rendering. This is simply a wrapper around `mdxish` that returns stringified HTML instead of HAST. This can be useful for testing/development or when we need a stringified version of the HAST._ ## 🧬 QA & Testing #### How to Test Locally To test this new rendering engine directly in the ReadMe app: - Open two terminals: - ReadMe (branch: mdxish-demo) - Markdown (this branch) - Link Markdown to ReadMe: - In markdown: `npm ci` && `npm run build` - In readme: `make link-markdown` - Run the ReadMe repo and open any project — it should not crash - Create docs using mixed Markdown/MDX (via Raw mode) and verify they render correctly - You can use the files in `tests/lib/mdxish/demo-docs` as examples in your editor #### Testing In The PR App We have prepared a PR app that has the new mdxish engine enabled by default for all projects. See it [here](https://readme-pr-16565.readme.ninja/) #### Things to Test in Docs - Built-in ReadMe components - User-defined components - Table of contents - Unclosed tags (e.g., `<br>`) - Links, headings, formatting, etc. ## 📸 Some Screenshots These screenshots are sample MD/MDX pages that is rendered using the new libraries. All screenshots here and all demo does not have correct validation _yet_. We purposefuly disabled validation to demo this new engine/library. | | | | | --- | --- | --- | | <img width="1920" height="1113" alt="Screenshot 2025-11-27 at 01 05 44" src="https://github.com/user-attachments/assets/1b8037c2-1d80-4980-8417-5f61411c5df8" /> | <img width="1920" height="1113" alt="Screenshot 2025-11-27 at 01 06 10" src="https://github.com/user-attachments/assets/37e597a3-6ef2-4844-b5fd-86604936b0b3" /> | <img width="1920" height="1113" alt="Screenshot 2025-11-27 at 01 06 27" src="https://github.com/user-attachments/assets/012b92f6-56f9-41b4-b000-6db4a1d346f1" /> | [demo]: https://markdown-pr-PR_NUMBER.herokuapp.com [prod]: https://SUBDOMAIN.readme.io [icn]: https://user-images.githubusercontent.com/886627/160426047-1bee9488-305a-4145-bb2b-09d8b757d38a.svg --------- Co-authored-by: Dimas Putra Anugerah <[email protected]> Co-authored-by: eagletrhost <[email protected]> Co-authored-by: Rafe Goldberg <[email protected]>
1 parent 8662f6b commit 9e8f85f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+6913
-257
lines changed

__tests__/benchmarks/engine.bench.ts

Lines changed: 538 additions & 0 deletions
Large diffs are not rendered by default.

__tests__/compilers.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { mdast, mdx } from '../index';
1+
import type { Element } from 'hast';
2+
3+
import { mdast, mdx, mdxish } from '../index';
24

35
describe('ReadMe Flavored Blocks', () => {
46
it('Embed', () => {
@@ -15,3 +17,28 @@ describe('ReadMe Flavored Blocks', () => {
1517
`);
1618
});
1719
});
20+
21+
describe('mdxish ReadMe Flavored Blocks', () => {
22+
it('Embed', () => {
23+
const txt = '[Embedded meta links.](https://nyti.me/s/gzoa2xb2v3 "@embed")';
24+
const hast = mdxish(txt);
25+
const embed = hast.children[0] as Element;
26+
27+
expect(embed.type).toBe('element');
28+
expect(embed.tagName).toBe('embed');
29+
expect(embed.properties.url).toBe('https://nyti.me/s/gzoa2xb2v3');
30+
expect(embed.properties.title).toBe('Embedded meta links.');
31+
});
32+
33+
it('Emojis', () => {
34+
const hast = mdxish(':smiley:');
35+
const paragraph = hast.children[0] as Element;
36+
37+
expect(paragraph.type).toBe('element');
38+
expect(paragraph.tagName).toBe('p');
39+
// gemojiTransformer converts :smiley: to 😃
40+
const textNode = paragraph.children[0];
41+
expect(textNode.type).toBe('text');
42+
expect('value' in textNode && textNode.value).toBe('😃');
43+
});
44+
});

__tests__/compilers/callout.test.ts

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import type { Element } from 'hast';
12
import type { Root } from 'mdast';
23

3-
import { mdast, mdx } from '../../index';
4+
import { mdast, mdx, mdxish } from '../../index';
45

56
describe('callouts compiler', () => {
67
it('compiles callouts', () => {
@@ -156,3 +157,134 @@ describe('callouts compiler', () => {
156157
expect(mdx(mockAst as Root).trim()).toBe(markdown);
157158
});
158159
});
160+
161+
describe('mdxish callout compiler', () => {
162+
it('compiles callouts', () => {
163+
const markdown = `> 🚧 It works!
164+
>
165+
> And, it no longer deletes your content!
166+
`;
167+
168+
const hast = mdxish(markdown);
169+
const callout = hast.children[0] as Element;
170+
171+
expect(callout.tagName).toBe('Callout');
172+
expect(callout.properties?.icon).toBe('🚧');
173+
expect(callout.properties?.theme).toBe('warn');
174+
expect(callout.children).toHaveLength(2); // h3 and p
175+
});
176+
177+
it('compiles callouts with no heading', () => {
178+
const markdown = `> 🚧
179+
>
180+
> And, it no longer deletes your content!
181+
`;
182+
183+
const hast = mdxish(markdown);
184+
const callout = hast.children[0] as Element;
185+
186+
expect(callout.tagName).toBe('Callout');
187+
expect(callout.properties?.icon).toBe('🚧');
188+
expect(callout.properties?.empty).toBe('');
189+
expect(callout.properties?.theme).toBe('warn');
190+
});
191+
192+
it('compiles callouts with no heading or body', () => {
193+
const markdown = `> 🚧
194+
`;
195+
196+
const hast = mdxish(markdown);
197+
const callout = hast.children[0] as Element;
198+
199+
expect(callout.tagName).toBe('Callout');
200+
expect(callout.properties?.icon).toBe('🚧');
201+
expect(callout.properties?.empty).toBe('');
202+
expect(callout.properties?.theme).toBe('warn');
203+
});
204+
205+
it('compiles callouts with no heading or body and no new line at the end', () => {
206+
const markdown = '> ℹ️';
207+
208+
const hast = mdxish(markdown);
209+
const callout = hast.children[0] as Element;
210+
211+
expect(callout.tagName).toBe('Callout');
212+
expect(callout.properties?.icon).toBe('ℹ️');
213+
expect(callout.properties?.empty).toBe('');
214+
expect(callout.properties?.theme).toBe('info');
215+
});
216+
217+
it('compiles callouts with markdown in the heading', () => {
218+
const markdown = `> 🚧 It **works**!
219+
>
220+
> And, it no longer deletes your content!
221+
`;
222+
223+
const hast = mdxish(markdown);
224+
const callout = hast.children[0] as Element;
225+
226+
expect(callout.tagName).toBe('Callout');
227+
expect(callout.properties?.icon).toBe('🚧');
228+
expect(callout.properties?.theme).toBe('warn');
229+
230+
const heading = callout.children[0] as Element;
231+
expect(heading.tagName).toBe('h3');
232+
expect(heading.properties?.id).toBe('it-works');
233+
});
234+
235+
it('compiles callouts with paragraphs', () => {
236+
const markdown = `> 🚧 It **works**!
237+
>
238+
> And...
239+
>
240+
> it correctly compiles paragraphs. :grimace:
241+
`;
242+
243+
const hast = mdxish(markdown);
244+
const callout = hast.children[0] as Element;
245+
246+
expect(callout.tagName).toBe('Callout');
247+
expect(callout.properties?.icon).toBe('🚧');
248+
expect(callout.properties?.theme).toBe('warn');
249+
expect(callout.children.length).toBeGreaterThan(1); // heading + multiple paragraphs
250+
});
251+
252+
it('compiles callouts with icons + theme', () => {
253+
const markdown = `
254+
<Callout icon="fad fa-wagon-covered" theme="warn">
255+
test
256+
</Callout>`.trim();
257+
258+
const hast = mdxish(markdown);
259+
const callout = hast.children[0] as Element;
260+
261+
expect(callout.tagName).toBe('Callout');
262+
expect(callout.properties?.icon).toBe('fad fa-wagon-covered');
263+
expect(callout.properties?.theme).toBe('warn');
264+
});
265+
266+
it('compiles a callout with only a theme set', () => {
267+
const markdown = '> 🚧 test';
268+
269+
const hast = mdxish(markdown);
270+
const callout = hast.children[0] as Element;
271+
272+
expect(callout.tagName).toBe('Callout');
273+
expect(callout.properties?.icon).toBe('🚧');
274+
expect(callout.properties?.theme).toBe('warn');
275+
276+
const heading = callout.children[0] as Element;
277+
expect(heading.tagName).toBe('h3');
278+
});
279+
280+
it('compiles a callout with only an icon set', () => {
281+
const markdown = '> 🚧 test';
282+
283+
const hast = mdxish(markdown);
284+
const callout = hast.children[0] as Element;
285+
286+
expect(callout.tagName).toBe('Callout');
287+
expect(callout.properties?.icon).toBe('🚧');
288+
expect(callout.properties?.theme).toBe('warn'); // defaults based on icon
289+
});
290+
});

__tests__/compilers/code-tabs.test.js

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mdast, mdx } from '../../index';
1+
import { mdast, mdx, mdxish } from '../../index';
22

33
describe('code-tabs compiler', () => {
44
it('compiles code tabs', () => {
@@ -41,3 +41,65 @@ I should stay here
4141
expect(mdx(mdast(markdown))).toBe(markdown);
4242
});
4343
});
44+
45+
describe('mdxish code-tabs compiler', () => {
46+
it('compiles code tabs', () => {
47+
const markdown = `\`\`\`
48+
const works = true;
49+
\`\`\`
50+
\`\`\`
51+
const cool = true;
52+
\`\`\`
53+
`;
54+
55+
const hast = mdxish(markdown);
56+
// Code blocks should be grouped into CodeTabs
57+
const firstChild = hast.children[0];
58+
59+
expect(firstChild.type).toBe('element');
60+
expect(firstChild.tagName).toBe('CodeTabs');
61+
expect(firstChild.children).toHaveLength(2); // Two code blocks
62+
});
63+
64+
it('compiles code tabs with metadata', () => {
65+
const markdown = `\`\`\`js Testing
66+
const works = true;
67+
\`\`\`
68+
\`\`\`js
69+
const cool = true;
70+
\`\`\`
71+
`;
72+
73+
const hast = mdxish(markdown);
74+
const firstChild = hast.children[0];
75+
76+
expect(firstChild.type).toBe('element');
77+
expect(firstChild.tagName).toBe('CodeTabs');
78+
expect(firstChild.children).toHaveLength(2); // Two code blocks
79+
});
80+
81+
it("doesnt't mess with joining other blocks", () => {
82+
const markdown = `\`\`\`
83+
const works = true;
84+
\`\`\`
85+
\`\`\`
86+
const cool = true;
87+
\`\`\`
88+
89+
## Hello!
90+
91+
I should stay here
92+
`;
93+
94+
const hast = mdxish(markdown);
95+
// CodeTabs should be first
96+
const firstChild = hast.children[0];
97+
expect(firstChild.type).toBe('element');
98+
expect(firstChild.tagName).toBe('CodeTabs');
99+
100+
// Then heading
101+
const heading = hast.children.find(c => c.type === 'element' && c.tagName === 'h2');
102+
expect(heading).toBeDefined();
103+
expect(heading.tagName).toBe('h2');
104+
});
105+
});

__tests__/compilers/compatability.test.tsx

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1+
import type { CustomComponents } from '../../types';
2+
import type { Element } from 'hast';
3+
14
import fs from 'node:fs';
25

36
import { render, screen } from '@testing-library/react';
4-
import React from 'react';
57

68
import { vi } from 'vitest';
79

8-
import { mdx, compile, run } from '../../index';
10+
import { mdx, compile, run, mdxish } from '../../index';
911
import { migrate } from '../helpers';
1012

1113
describe('compatability with RDMD', () => {
@@ -507,3 +509,74 @@ ${JSON.stringify(
507509
`);
508510
});
509511
});
512+
513+
describe('mdxish compatability with RDMD', () => {
514+
it('processes Glossary component', () => {
515+
const markdown = '<Glossary>parliament</Glossary>';
516+
517+
const hast = mdxish(markdown);
518+
const paragraph = hast.children[0] as Element;
519+
const glossary = paragraph.children[0] as Element;
520+
521+
expect(paragraph.type).toBe('element');
522+
expect(paragraph.tagName).toBe('p');
523+
expect(glossary.type).toBe('element');
524+
expect(glossary.tagName).toBe('Glossary');
525+
const textNode = glossary.children[0];
526+
expect(textNode.type).toBe('text');
527+
expect('value' in textNode && textNode.value).toBe('parliament');
528+
});
529+
530+
it('processes Image component with attributes and caption', () => {
531+
const markdown = `<Image align="center" width="300px" src="https://drastik.ch/wp-content/uploads/2023/06/blackcat.gif" border={true}>
532+
hello **cat**
533+
</Image>`;
534+
535+
const hast = mdxish(markdown.trim());
536+
const image = hast.children[0] as Element;
537+
538+
expect(image.type).toBe('element');
539+
expect(image.tagName).toBe('img');
540+
expect(image.properties.align).toBe('center');
541+
expect(image.properties.width).toBe('300px');
542+
expect(image.properties.src).toBe('https://drastik.ch/wp-content/uploads/2023/06/blackcat.gif');
543+
expect(image.properties.border).toBe('true');
544+
// Caption text should be processed (but Image components don't support captions in mdxish)
545+
});
546+
547+
it('processes Embed component with attributes', () => {
548+
const markdown =
549+
'<Embed url="https://cdn.shopify.com/s/files/1/0711/5132/1403/files/BRK0502-034178M.pdf" title="iframe" href="https://cdn.shopify.com/s/files/1/0711/5132/1403/files/BRK0502-034178M.pdf" typeOfEmbed="iframe" height="300px" width="100%" iframe="true" />';
550+
551+
const hast = mdxish(markdown);
552+
const embed = hast.children[0] as Element;
553+
554+
expect(embed.type).toBe('element');
555+
expect(embed.tagName).toBe('embed');
556+
expect(embed.properties.url).toBe('https://cdn.shopify.com/s/files/1/0711/5132/1403/files/BRK0502-034178M.pdf');
557+
expect(embed.properties.title).toBe('iframe');
558+
expect(embed.properties.typeOfEmbed).toBe('iframe');
559+
expect(embed.properties.height).toBe('300px');
560+
expect(embed.properties.width).toBe('100%');
561+
expect(embed.properties.iframe).toBe('true');
562+
});
563+
564+
it('processes reusable content component', () => {
565+
const markdown = '<Parliament />';
566+
567+
const hast = mdxish(markdown, {
568+
components: {
569+
Parliament: '# Parliament',
570+
},
571+
} as unknown as CustomComponents);
572+
573+
// Component is recognized and preserved in HAST
574+
expect(hast.children.length).toBeGreaterThan(0);
575+
const component = hast.children.find(
576+
child => child.type === 'element' && (child as Element).tagName === 'Parliament',
577+
) as Element | undefined;
578+
expect(component).toBeDefined();
579+
expect(component?.type).toBe('element');
580+
expect(component?.tagName).toBe('Parliament');
581+
});
582+
});

__tests__/compilers/escape.test.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mdast, mdx } from '../../index';
1+
import { mdast, mdx, mdxish } from '../../index';
22

33
describe('escape compiler', () => {
44
it('handles escapes', () => {
@@ -7,3 +7,17 @@ describe('escape compiler', () => {
77
expect(mdx(mdast(txt))).toBe('\\&para;\n');
88
});
99
});
10+
11+
describe('mdxish escape compiler', () => {
12+
it('handles escapes', () => {
13+
const txt = '\\&para;';
14+
15+
const hast = mdxish(txt);
16+
const paragraph = hast.children[0];
17+
18+
expect(paragraph.type).toBe('element');
19+
expect(paragraph.tagName).toBe('p');
20+
expect(paragraph.children[0].type).toBe('text');
21+
expect(paragraph.children[0].value).toBe('&para;');
22+
});
23+
});

0 commit comments

Comments
 (0)