Skip to content

Commit b742c6c

Browse files
feat: jsx-coercion (#876)
| [![PR App][icn]][demo] | Fix RM-9722 | | :--------------------: | :---------: | ## 🧰 Changes Coerces readme JSX components to mdast nodes. This is to support the editor, it _should_ just make the editor widgets just work. I say it _should_, but I haven't been able to test it yet. ## 🧬 QA & Testing - [Broken on production][prod]. - [Working in this PR app][demo]. [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: Jon Ursenbach <erunion@users.noreply.github.com>
1 parent 76da750 commit b742c6c

File tree

16 files changed

+298
-95
lines changed

16 files changed

+298
-95
lines changed

__tests__/matchers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { expect } from 'vitest';
2+
import { map } from 'unist-util-map';
3+
4+
import type { ExpectationResult } from '@vitest/expect';
5+
import { Root, Node } from 'mdast';
6+
7+
const removePosition = ({ position, ...node }: Node) => node;
8+
9+
function toStrictEqualExceptPosition(received: Root, expected: Root): ExpectationResult {
10+
const { equals } = this;
11+
const receivedTrimmed = map(received, removePosition);
12+
const expectedTrimmed = map(expected, removePosition);
13+
14+
return {
15+
pass: equals(receivedTrimmed, expectedTrimmed),
16+
message: () => 'Expected two trees to be equal!',
17+
actual: receivedTrimmed,
18+
expected: expectedTrimmed,
19+
};
20+
}
21+
22+
expect.extend({ toStrictEqualExceptPosition });

__tests__/transformers/code-tabs.test.ts

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -44,26 +44,10 @@ Second code block
4444
`;
4545
const ast = mdast(md);
4646

47-
expect(ast.children[0].children[0].data).toMatchInlineSnapshot(`
48-
{
49-
"hName": "Code",
50-
"hProperties": {
51-
"lang": "javascript",
52-
"meta": "First Title",
53-
"value": "First code block",
54-
},
55-
}
56-
`);
57-
expect(ast.children[0].children[1].data).toMatchInlineSnapshot(`
58-
{
59-
"hName": "Code",
60-
"hProperties": {
61-
"lang": "text",
62-
"meta": null,
63-
"value": "Second code block",
64-
},
65-
}
66-
`);
47+
expect(ast.children[0].children[0]).toStrictEqual(
48+
expect.objectContaining({ lang: 'javascript', meta: 'First Title' }),
49+
);
50+
expect(ast.children[0].children[1]).toStrictEqual(expect.objectContaining({ lang: 'text', meta: null }));
6751
});
6852

6953
it('wraps single code blocks with tabs if they have a lang set', () => {
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { mdast } from '../../index';
2+
3+
describe('Readme Components Transformer', () => {
4+
const nodes = [
5+
{ md: '<Callout />', type: 'rdme-callout' },
6+
{ md: '<Code />', type: 'code' },
7+
{ md: '<CodeTabs />', type: 'code-tabs' },
8+
{ md: '<Image />', type: 'image' },
9+
{ md: '<Table />', type: 'table' },
10+
];
11+
12+
it.each(nodes)('transforms $md into a(n) $type node', ({ md, type }) => {
13+
const tree = mdast(md);
14+
15+
expect(tree.children[0].type).toBe(type);
16+
});
17+
18+
const docs = {
19+
['rdme-callout']: {
20+
md: `> 📘 It works!`,
21+
mdx: `<Callout icon="📘" heading="It works!" />`,
22+
},
23+
code: {
24+
md: `
25+
~~~
26+
This is a code block
27+
~~~
28+
`,
29+
mdx: `<Code value="This is a code block" />`,
30+
},
31+
['code-tabs']: {
32+
md: `
33+
~~~
34+
First
35+
~~~
36+
~~~
37+
Second
38+
~~~
39+
`,
40+
mdx: `
41+
<CodeTabs>
42+
<Code value='First' />
43+
<Code value='Second' />
44+
</CodeTabs>
45+
`,
46+
},
47+
image: {
48+
md: `![](http://placekitten.com/600/200)`,
49+
mdx: `<Image src="http://placekitten.com/600/200" />`,
50+
},
51+
table: {
52+
md: `
53+
| h1 | h2 |
54+
| --- | --- |
55+
| a1 | a2 |
56+
`,
57+
// @todo there's text nodes that get inserted between the td's. Pretty sure
58+
// they'd get filtered out by rehype, but lets keep the tests easy.
59+
mdx: `
60+
<Table>
61+
<tr>
62+
<td>h1</td><td>h2</td>
63+
</tr>
64+
<tr>
65+
<td>a1</td><td>a2</td>
66+
</tr>
67+
</Table>
68+
`,
69+
},
70+
};
71+
it.each(Object.entries(docs))('matches the equivalent markdown for %s', (type, { md, mdx }) => {
72+
let mdTree = mdast(md);
73+
const mdxTree = mdast(mdx);
74+
75+
if (type === 'image') {
76+
// @todo something about these dang paragraphs!
77+
mdTree = {
78+
type: 'root',
79+
children: mdTree.children[0].children,
80+
};
81+
}
82+
83+
expect(mdxTree).toStrictEqualExceptPosition(mdTree);
84+
});
85+
86+
it('does not convert components that have custom implementations', () => {
87+
const mdx = `
88+
<Callout heading="Much wow" icon="❗" />
89+
`;
90+
91+
const tree = mdast(mdx, {
92+
components: {
93+
Callout: () => null,
94+
},
95+
});
96+
97+
expect(tree.children[0].type).toBe('mdxJsxFlowElement');
98+
expect(tree.children[0].name).toBe('Callout');
99+
});
100+
});

components/Callout/index.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,29 @@ import * as React from 'react';
33
interface Props extends React.PropsWithChildren<React.HTMLAttributes<HTMLQuoteElement>> {
44
attributes: {};
55
icon: string;
6-
theme: string;
6+
theme?: string;
77
heading?: React.ReactElement;
88
}
99

10+
const themes: Record<string, string> = {
11+
'\uD83D\uDCD8': 'info',
12+
'\uD83D\uDEA7': 'warn',
13+
'\u26A0\uFE0F': 'warn',
14+
'\uD83D\uDC4D': 'okay',
15+
'\u2705': 'okay',
16+
'\u2757\uFE0F': 'error',
17+
'\u2757': 'error',
18+
'\uD83D\uDED1': 'error',
19+
'\u2049\uFE0F': 'error',
20+
'\u203C\uFE0F': 'error',
21+
'\u2139\uFE0F': 'info',
22+
'\u26A0': 'warn',
23+
};
24+
1025
const Callout = (props: Props) => {
11-
const { attributes, children, theme, icon, heading } = props;
26+
const { attributes, children, icon, heading } = props;
27+
28+
let theme = props.theme || themes[icon] || 'default';
1229

1330
return (
1431
// @ts-ignore

components/Code/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const Code = (props: Props) => {
5252
dark: theme === 'dark',
5353
};
5454

55-
const code = value ?? children?.[0] ?? children ?? '';
55+
const code = value ?? (Array.isArray(children) ? children[0] : children) ?? '';
5656
const highlightedCode = syntaxHighlighter && code ? syntaxHighlighter(code, language, codeOpts) : code;
5757

5858
return (

index.tsx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,23 @@ import { GlossaryContext } from './components/GlossaryItem';
1616
import BaseUrlContext from './contexts/BaseUrl';
1717
import { options } from './options';
1818

19-
import transformers from './processor/transform';
19+
import transformers, { readmeComponentsTransformer } from './processor/transform';
2020
import compilers from './processor/compile';
2121
import MdxSyntaxError from './errors/mdx-syntax-error';
2222

2323
const unimplemented = debug('mdx:unimplemented');
2424

25+
type ComponentOpts = Record<string, () => React.ReactNode>;
26+
2527
type RunOpts = Omit<RunOptions, 'Fragment'> & {
26-
components?: Record<string, () => React.ReactNode>;
28+
components?: ComponentOpts;
2729
imports?: Record<string, unknown>;
2830
};
2931

32+
type MdastOpts = {
33+
components?: ComponentOpts;
34+
};
35+
3036
export { Components };
3137

3238
export const utils = {
@@ -46,6 +52,7 @@ const makeUseMDXComponents = (more: RunOpts['components']) => {
4652
...more,
4753
...Components,
4854
Variable,
55+
code: Components.Code,
4956
'code-tabs': Components.CodeTabs,
5057
'html-block': Components.HTMLBlock,
5158
img: Components.Image,
@@ -72,7 +79,8 @@ export const compile = (text: string, opts = {}) => {
7279
}),
7380
).replace(/await import\(_resolveDynamicMdxSpecifier\('react'\)\)/, 'arguments[0].imports.React');
7481
} catch (error) {
75-
throw new MdxSyntaxError(error, text);
82+
console.error(error);
83+
throw error.line ? new MdxSyntaxError(error, text) : error;
7684
}
7785
};
7886

@@ -104,9 +112,14 @@ export const html = (text: string, opts = {}) => {
104112
unimplemented('html export');
105113
};
106114

107-
const astProcessor = (opts = {}) => remark().use(remarkMdx).use(remarkFrontmatter).use(remarkPlugins);
115+
const astProcessor = (opts = {}) =>
116+
remark()
117+
.use(remarkMdx)
118+
.use(remarkFrontmatter)
119+
.use(remarkPlugins)
120+
.use(readmeComponentsTransformer, { components: opts.components });
108121

109-
export const mdast: any = (text: string, opts = {}) => {
122+
export const mdast: any = (text: string, opts: MdastOpts = {}) => {
110123
const processor = astProcessor(opts);
111124

112125
const tree = processor.parse(text);

package-lock.json

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
"terser-webpack-plugin": "^5.3.7",
112112
"ts-loader": "^9.4.2",
113113
"typescript": "^5.4.5",
114+
"unist-util-map": "^4.0.0",
114115
"vitest": "^1.4.0",
115116
"webpack": "^5.56.0",
116117
"webpack-cli": "^5.0.1",

processor/transform/callouts.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,6 @@ import { Blockquote, BlockContent, Parent, DefinitionContent } from 'mdast';
44

55
const regex = `^(${emojiRegex().source}|⚠)(\\s+|$)`;
66

7-
const themes: Record<string, string> = {
8-
'\uD83D\uDCD8': 'info',
9-
'\uD83D\uDEA7': 'warn',
10-
'\u26A0\uFE0F': 'warn',
11-
'\uD83D\uDC4D': 'okay',
12-
'\u2705': 'okay',
13-
'\u2757\uFE0F': 'error',
14-
'\u2757': 'error',
15-
'\uD83D\uDED1': 'error',
16-
'\u2049\uFE0F': 'error',
17-
'\u203C\uFE0F': 'error',
18-
'\u2139\uFE0F': 'info',
19-
'\u26A0': 'warn',
20-
};
21-
22-
const toString = (node: Node): string => {
23-
if ('value' in node && node.value) return node.value as string;
24-
if ('children' in node && node.children) return (node.children as Node[]).map(child => toString(child)).join('');
25-
return '';
26-
};
27-
287
interface Callout extends Parent {
298
type: 'rdme-callout';
309
children: Array<BlockContent | DefinitionContent>;
@@ -49,7 +28,6 @@ const calloutTransformer = () => {
4928
hProperties: {
5029
heading,
5130
icon,
52-
theme: themes[icon] || 'default',
5331
},
5432
};
5533
}

processor/transform/code-tabs.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ const codeTabs = () => tree => {
88
const { lang, meta, value } = node;
99

1010
node.data = {
11-
hName: 'Code',
1211
hProperties: { lang, meta, value },
1312
};
1413
});

0 commit comments

Comments
 (0)