Skip to content

Commit

Permalink
feat: jsx-coercion (#876)
Browse files Browse the repository at this point in the history
| [![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>
  • Loading branch information
kellyjosephprice and erunion committed May 16, 2024
1 parent 76da750 commit b742c6c
Show file tree
Hide file tree
Showing 16 changed files with 298 additions and 95 deletions.
22 changes: 22 additions & 0 deletions __tests__/matchers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect } from 'vitest';
import { map } from 'unist-util-map';

import type { ExpectationResult } from '@vitest/expect';
import { Root, Node } from 'mdast';

const removePosition = ({ position, ...node }: Node) => node;

function toStrictEqualExceptPosition(received: Root, expected: Root): ExpectationResult {
const { equals } = this;
const receivedTrimmed = map(received, removePosition);
const expectedTrimmed = map(expected, removePosition);

return {
pass: equals(receivedTrimmed, expectedTrimmed),
message: () => 'Expected two trees to be equal!',
actual: receivedTrimmed,
expected: expectedTrimmed,
};
}

expect.extend({ toStrictEqualExceptPosition });
24 changes: 4 additions & 20 deletions __tests__/transformers/code-tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,10 @@ Second code block
`;
const ast = mdast(md);

expect(ast.children[0].children[0].data).toMatchInlineSnapshot(`
{
"hName": "Code",
"hProperties": {
"lang": "javascript",
"meta": "First Title",
"value": "First code block",
},
}
`);
expect(ast.children[0].children[1].data).toMatchInlineSnapshot(`
{
"hName": "Code",
"hProperties": {
"lang": "text",
"meta": null,
"value": "Second code block",
},
}
`);
expect(ast.children[0].children[0]).toStrictEqual(
expect.objectContaining({ lang: 'javascript', meta: 'First Title' }),
);
expect(ast.children[0].children[1]).toStrictEqual(expect.objectContaining({ lang: 'text', meta: null }));
});

it('wraps single code blocks with tabs if they have a lang set', () => {
Expand Down
100 changes: 100 additions & 0 deletions __tests__/transformers/readme-components.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { mdast } from '../../index';

describe('Readme Components Transformer', () => {
const nodes = [
{ md: '<Callout />', type: 'rdme-callout' },
{ md: '<Code />', type: 'code' },
{ md: '<CodeTabs />', type: 'code-tabs' },
{ md: '<Image />', type: 'image' },
{ md: '<Table />', type: 'table' },
];

it.each(nodes)('transforms $md into a(n) $type node', ({ md, type }) => {
const tree = mdast(md);

expect(tree.children[0].type).toBe(type);
});

const docs = {
['rdme-callout']: {
md: `> 📘 It works!`,
mdx: `<Callout icon="📘" heading="It works!" />`,
},
code: {
md: `
~~~
This is a code block
~~~
`,
mdx: `<Code value="This is a code block" />`,
},
['code-tabs']: {
md: `
~~~
First
~~~
~~~
Second
~~~
`,
mdx: `
<CodeTabs>
<Code value='First' />
<Code value='Second' />
</CodeTabs>
`,
},
image: {
md: `![](http://placekitten.com/600/200)`,
mdx: `<Image src="http://placekitten.com/600/200" />`,
},
table: {
md: `
| h1 | h2 |
| --- | --- |
| a1 | a2 |
`,
// @todo there's text nodes that get inserted between the td's. Pretty sure
// they'd get filtered out by rehype, but lets keep the tests easy.
mdx: `
<Table>
<tr>
<td>h1</td><td>h2</td>
</tr>
<tr>
<td>a1</td><td>a2</td>
</tr>
</Table>
`,
},
};
it.each(Object.entries(docs))('matches the equivalent markdown for %s', (type, { md, mdx }) => {
let mdTree = mdast(md);
const mdxTree = mdast(mdx);

if (type === 'image') {
// @todo something about these dang paragraphs!
mdTree = {
type: 'root',
children: mdTree.children[0].children,
};
}

expect(mdxTree).toStrictEqualExceptPosition(mdTree);
});

it('does not convert components that have custom implementations', () => {
const mdx = `
<Callout heading="Much wow" icon="❗" />
`;

const tree = mdast(mdx, {
components: {
Callout: () => null,
},
});

expect(tree.children[0].type).toBe('mdxJsxFlowElement');
expect(tree.children[0].name).toBe('Callout');
});
});
21 changes: 19 additions & 2 deletions components/Callout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,29 @@ import * as React from 'react';
interface Props extends React.PropsWithChildren<React.HTMLAttributes<HTMLQuoteElement>> {
attributes: {};
icon: string;
theme: string;
theme?: string;
heading?: React.ReactElement;
}

const themes: Record<string, string> = {
'\uD83D\uDCD8': 'info',
'\uD83D\uDEA7': 'warn',
'\u26A0\uFE0F': 'warn',
'\uD83D\uDC4D': 'okay',
'\u2705': 'okay',
'\u2757\uFE0F': 'error',
'\u2757': 'error',
'\uD83D\uDED1': 'error',
'\u2049\uFE0F': 'error',
'\u203C\uFE0F': 'error',
'\u2139\uFE0F': 'info',
'\u26A0': 'warn',
};

const Callout = (props: Props) => {
const { attributes, children, theme, icon, heading } = props;
const { attributes, children, icon, heading } = props;

let theme = props.theme || themes[icon] || 'default';

return (
// @ts-ignore
Expand Down
2 changes: 1 addition & 1 deletion components/Code/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const Code = (props: Props) => {
dark: theme === 'dark',
};

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

return (
Expand Down
23 changes: 18 additions & 5 deletions index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@ import { GlossaryContext } from './components/GlossaryItem';
import BaseUrlContext from './contexts/BaseUrl';
import { options } from './options';

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

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

type ComponentOpts = Record<string, () => React.ReactNode>;

type RunOpts = Omit<RunOptions, 'Fragment'> & {
components?: Record<string, () => React.ReactNode>;
components?: ComponentOpts;
imports?: Record<string, unknown>;
};

type MdastOpts = {
components?: ComponentOpts;
};

export { Components };

export const utils = {
Expand All @@ -46,6 +52,7 @@ const makeUseMDXComponents = (more: RunOpts['components']) => {
...more,
...Components,
Variable,
code: Components.Code,
'code-tabs': Components.CodeTabs,
'html-block': Components.HTMLBlock,
img: Components.Image,
Expand All @@ -72,7 +79,8 @@ export const compile = (text: string, opts = {}) => {
}),
).replace(/await import\(_resolveDynamicMdxSpecifier\('react'\)\)/, 'arguments[0].imports.React');
} catch (error) {
throw new MdxSyntaxError(error, text);
console.error(error);
throw error.line ? new MdxSyntaxError(error, text) : error;
}
};

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

const astProcessor = (opts = {}) => remark().use(remarkMdx).use(remarkFrontmatter).use(remarkPlugins);
const astProcessor = (opts = {}) =>
remark()
.use(remarkMdx)
.use(remarkFrontmatter)
.use(remarkPlugins)
.use(readmeComponentsTransformer, { components: opts.components });

Check failure on line 120 in index.tsx

View workflow job for this annotation

GitHub Actions / Bundle Watch

Property 'components' does not exist on type '{}'.

Check failure on line 120 in index.tsx

View workflow job for this annotation

GitHub Actions / Release

Property 'components' does not exist on type '{}'.

export const mdast: any = (text: string, opts = {}) => {
export const mdast: any = (text: string, opts: MdastOpts = {}) => {
const processor = astProcessor(opts);

const tree = processor.parse(text);
Expand Down
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"terser-webpack-plugin": "^5.3.7",
"ts-loader": "^9.4.2",
"typescript": "^5.4.5",
"unist-util-map": "^4.0.0",
"vitest": "^1.4.0",
"webpack": "^5.56.0",
"webpack-cli": "^5.0.1",
Expand Down
22 changes: 0 additions & 22 deletions processor/transform/callouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,6 @@ import { Blockquote, BlockContent, Parent, DefinitionContent } from 'mdast';

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

const themes: Record<string, string> = {
'\uD83D\uDCD8': 'info',
'\uD83D\uDEA7': 'warn',
'\u26A0\uFE0F': 'warn',
'\uD83D\uDC4D': 'okay',
'\u2705': 'okay',
'\u2757\uFE0F': 'error',
'\u2757': 'error',
'\uD83D\uDED1': 'error',
'\u2049\uFE0F': 'error',
'\u203C\uFE0F': 'error',
'\u2139\uFE0F': 'info',
'\u26A0': 'warn',
};

const toString = (node: Node): string => {
if ('value' in node && node.value) return node.value as string;
if ('children' in node && node.children) return (node.children as Node[]).map(child => toString(child)).join('');
return '';
};

interface Callout extends Parent {
type: 'rdme-callout';
children: Array<BlockContent | DefinitionContent>;
Expand All @@ -49,7 +28,6 @@ const calloutTransformer = () => {
hProperties: {
heading,
icon,
theme: themes[icon] || 'default',
},
};
}
Expand Down
1 change: 0 additions & 1 deletion processor/transform/code-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const codeTabs = () => tree => {
const { lang, meta, value } = node;

node.data = {
hName: 'Code',
hProperties: { lang, meta, value },
};
});
Expand Down
3 changes: 3 additions & 0 deletions processor/transform/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import calloutTransformer from './callouts';
import codeTabsTransfromer from './code-tabs';
import gemojiTransformer from './gemoji+';
import readmeComponentsTransformer from './readme-components';

export { readmeComponentsTransformer };

export default [calloutTransformer, codeTabsTransfromer, gemojiTransformer];
Loading

0 comments on commit b742c6c

Please sign in to comment.