Skip to content

Commit

Permalink
feat: compatibility (#881)
Browse files Browse the repository at this point in the history
| [![PR App][icn]][demo] | Fix RM-9827 |
| :--------------------: | :---------: |

## 🧰 Changes

Adds backwards compatibility with variables, glossary, and reusable
content.

When we're trying to update a document from markdown to MDX, we first
parse the document to an mdast using the old markdown library. Then we
serialize it back to MDX with this version. Basically:

```
const mdx = rmdx.mdx(rdmd.mdast(doc))
```

But, because these types are now completely represented in JSX, we
didn't have any custom compilers to handle them. This adds some
compilers to write their nodes back out as JSX.

### Callouts

Also, refactors how callouts are represented. Adds a prop to the node
`empty` to represent if there's a heading a not. It's a bit more
plumbing to get mdx to parse and execute props, so this leaves the
heading as a child.

### HTML Comments

To be able to migrate away, we need to replace html comments with JSX
comments.

## 🧬 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
  • Loading branch information
kellyjosephprice authored May 23, 2024
1 parent 6d8dbdc commit 6432be8
Show file tree
Hide file tree
Showing 18 changed files with 135 additions and 54 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions __tests__/compilers/compatability.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { mdx } from '../../index';

describe('compatability with RDMD', () => {
it('compiles variable nodes', () => {
const ast = {
type: 'readme-variable',
text: 'parliament',
data: {
hName: 'readme-variable',
hProperties: {
variable: 'parliament',
},
},
};

expect(mdx(ast).trim()).toBe('<Variable name="parliament" />');
});

it('compiles glossary nodes', () => {
const ast = {
type: 'readme-glossary-item',
data: {
hProperties: {
term: 'parliament',
},
},
};

expect(mdx(ast).trim()).toBe('<Glossary>parliament</Glossary>');
});

it('compiles reusable-content nodes', () => {
const ast = {
type: 'reusable-content',
tag: 'Parliament',
};

expect(mdx(ast).trim()).toBe('<Parliament />');
});

it('compiles html comments to JSX comments', () => {
const ast = {
type: 'html',
value: '<!-- commentable -->',
};

expect(mdx(ast).trim()).toBe('{/* commentable */}');
});
});
2 changes: 1 addition & 1 deletion __tests__/components/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('Components', () => {
component = await run(code1);
({ container } = render(React.createElement(component)));

expect(container.innerHTML).toMatchInlineSnapshot(`"<blockquote class="callout callout_warn" theme="🚧"><h3 class="callout-heading empty"><span class="callout-icon">🚧</span></h3><p>Callout with no title.</p></blockquote>"`);
expect(container.innerHTML).toMatchInlineSnapshot(`"<blockquote class="callout callout_warn" theme="🚧"><h3 class="callout-heading empty"><span class="callout-icon">🚧</span></h3><p></p><p>Callout with no title.</p></blockquote>"`);

cleanup();
});
Expand Down
11 changes: 9 additions & 2 deletions __tests__/parsers/__snapshots__/callouts.test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ exports[`Parse RDMD Callouts > renders an info callout 1`] = `
},
},
"type": "text",
"value": "ℹ️ Info Callout",
"value": "Info Callout",
},
],
"position": {
Expand Down Expand Up @@ -72,6 +72,13 @@ exports[`Parse RDMD Callouts > renders an info callout 1`] = `
"type": "paragraph",
},
],
"data": {
"hName": "Callout",
"hProperties": {
"empty": false,
"icon": "ℹ️",
},
},
"position": {
"end": {
"column": 60,
Expand All @@ -84,7 +91,7 @@ exports[`Parse RDMD Callouts > renders an info callout 1`] = `
"offset": 1,
},
},
"type": "blockquote",
"type": "rdme-callout",
},
],
"position": {
Expand Down
32 changes: 10 additions & 22 deletions __tests__/parsers/callouts.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mdast } from '../../index';

describe.skip('Parse RDMD Callouts', () => {
describe('Parse RDMD Callouts', () => {
it('renders an info callout', () => {
const text = `
> ℹ️ Info Callout
Expand All @@ -10,18 +10,6 @@ describe.skip('Parse RDMD Callouts', () => {
expect(mdast(text)).toMatchSnapshot();
});

it('supports a default theme', () => {
const text = `
> 🥇 Themeless
>
> Lorem ipsum dolor sit amet consectetur adipisicing elit.`;

const tree = mdast(text);

expect(tree.children[0].type).toBe('rdme-callout');
expect(tree.children[0].data.hProperties.theme).toBe('default');
});

it('parses a callout with no title', () => {
const text = `
> ℹ️
Expand All @@ -31,8 +19,8 @@ describe.skip('Parse RDMD Callouts', () => {
const tree = mdast(text);

expect(tree.children[0].type).toBe('rdme-callout');
expect(tree.children[0].data.hProperties.theme).toBe('info');
expect(tree.children[0].data.hProperties.title).toBe('');
expect(tree.children[0].data.hProperties.icon).toBe('ℹ️');
expect(tree.children[0].data.hProperties.empty).toBe(true);
});

describe('edge cases', () => {
Expand All @@ -44,8 +32,8 @@ describe.skip('Parse RDMD Callouts', () => {
`;

const tree = mdast(text);
expect(tree.children[0].data.hProperties.value).toBe('<span>With html!</span>');
expect(tree.children[0].children[1].children[0].type).toBe('html');
expect(tree.children[0].children[1].children[0].children[0].value).toBe('With html!');
expect(tree.children[0].children[1].children[0].type).toBe('mdxJsxTextElement');
});

it('allows trailing spaces after the icon', () => {
Expand All @@ -56,7 +44,7 @@ describe.skip('Parse RDMD Callouts', () => {
const tree = mdast(text);
expect(tree.children[0].data.hProperties.icon).toBe('🛑');
expect(tree.children[0].children[0].children[0].value).toBe(
'Compact headings must be followed by two line breaks before the following block.'
'Compact headings must be followed by two line breaks before the following block.',
);
});
});
Expand All @@ -83,20 +71,20 @@ describe.skip('Parse RDMD Callouts', () => {
expect(tree.children[0].children[0].children[1].type).toBe('rdme-callout');
});

it('does not require a line break between the title and the body', () => {
it('does require a line break between the title and the body', () => {
const text = `
> 💁 Undocumented Behavior
> Lorem ipsum dolor sit amet consectetur adipisicing elit.`;

const tree = mdast(text);
expect(tree.children[0].data.hProperties.title).toBe(
expect(tree.children[0].children[0].children[0].value).toBe(
`Undocumented Behavior
Lorem ipsum dolor sit amet consectetur adipisicing elit.`
Lorem ipsum dolor sit amet consectetur adipisicing elit.`,
);
});
});

describe.skip('emoji modifier support', () => {
describe('emoji modifier support', () => {
const emojis = ['📘', '🚧', '⚠️', '👍', '✅', '❗️', '❗', '🛑', '⁉️', '‼️', 'ℹ️', '⚠'];

emojis.forEach(emoji => {
Expand Down
2 changes: 1 addition & 1 deletion __tests__/transformers/callouts.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mdast, hast } from '../../index';
import { mdast } from '../../index';

describe('callouts transformer', () => {
it('can parse callouts', () => {
Expand Down
5 changes: 4 additions & 1 deletion __tests__/transformers/readme-components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ describe('Readme Components Transformer', () => {
const docs = {
['rdme-callout']: {
md: `> 📘 It works!`,
mdx: `<Callout icon="📘" heading="It works!" />`,
mdx: `
<Callout icon="📘">
It works!
</Callout>`,
},
code: {
md: `
Expand Down
11 changes: 4 additions & 7 deletions components/Callout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ interface Props extends React.PropsWithChildren<React.HTMLAttributes<HTMLQuoteEl
attributes?: {};
icon: string;
theme?: string;
heading?: React.ReactElement;
empty?: boolean;
}

const themes: Record<string, string> = {
Expand All @@ -23,20 +23,17 @@ const themes: Record<string, string> = {
};

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

const { attributes, children, icon, empty } = props;
let theme = props.theme || themes[icon] || 'default';
const [heading, ...body] = Array.isArray(children) ? children : [children];
const empty = !heading.props.children;

return (
// @ts-ignore
<blockquote {...attributes} className={`callout callout_${theme}`} theme={icon}>
<h3 className={`callout-heading${empty ? ' empty' : ''}`}>
<span className="callout-icon">{icon}</span>
{!empty && heading}
{empty || children[0]}
</h3>
{body}
{empty ? children : React.Children.toArray(children).slice(1)}
</blockquote>
);
};
Expand Down
6 changes: 4 additions & 2 deletions docs/callout-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ hidden: true
> - was only rendering
> - one child
<Callout heading="Now with MDX" theme="error" icon="🔥">
### Even supports markdown
<Callout theme="error" icon="🔥">
Now with MDX

### Even supports markdown

Much _wow_
</Callout>
5 changes: 4 additions & 1 deletion enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ export enum NodeTypes {
i = 'i',
image = 'image',
htmlBlock = 'html-block',
embed = 'rdme-embed'
embed = 'rdme-embed',
variable = 'readme-variable',
glossary = 'readme-glossary-item',
reusableContent = 'reusable-content',
}
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
},
"peerDependencies": {
"@mdx-js/react": "^3.0.0",
"@readme/variable": "^16.0.0",
"@readme/variable": "^16.1.0",
"@tippyjs/react": "^4.1.0",
"react": "16.x || 17.x || 18.x",
"react-dom": "16.x || 17.x || 18.x"
Expand Down
25 changes: 25 additions & 0 deletions processor/compile/compatibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Html } from 'mdast';
import { NodeTypes } from '../../enums';

type CompatNodes =
| { type: NodeTypes.variable; text: string }
| { type: NodeTypes.glossary; data: { hProperties: { term: string } } }
| { type: NodeTypes.reusableContent; tag: string }
| Html;

const compatibility = (node: CompatNodes) => {
switch (node.type) {
case NodeTypes.variable:
return `<Variable name="${node.text}" />`;
case NodeTypes.glossary:
return `<Glossary>${node.data.hProperties.term}</Glossary>`;
case NodeTypes.reusableContent:
return `<${node.tag} />`;
case 'html':
return node.value.replaceAll(/<!--(.*)-->/g, '{/*$1*/}');
default:
throw new Error('Unhandled node type!');
}
};

export default compatibility;
5 changes: 5 additions & 0 deletions processor/compile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import embed from './embed';
import gemoji from './gemoji';
import htmlBlock from './html-block';
import image from './image';
import compatibility from './compatibility';
import { NodeTypes } from '../../enums';

function compilers() {
Expand All @@ -18,6 +19,10 @@ function compilers() {
[NodeTypes.embed]: embed,
[NodeTypes.htmlBlock]: htmlBlock,
[NodeTypes.image]: image,
[NodeTypes.variable]: compatibility,
[NodeTypes.glossary]: compatibility,
[NodeTypes.reusableContent]: compatibility,
html: compatibility,
};

toMarkdownExtensions.push({ extensions: [{ handlers }] });
Expand Down
17 changes: 10 additions & 7 deletions processor/transform/callouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ const calloutTransformer = () => {

if (icon && match) {
const heading = startText.slice(match.length);

node.children[0].children[0].value = heading;
node.type = NodeTypes.callout;
node.data = {
hName: 'Callout',
hProperties: {
icon,

Object.assign(node, {
type: NodeTypes.callout,
data: {
hName: 'Callout',
hProperties: {
icon,
empty: !heading.length,
},
},
};
});
}
});
};
Expand Down
8 changes: 3 additions & 5 deletions processor/transform/readme-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,17 +78,15 @@ const coerceJsxToMd =

parent.children[index] = mdNode;
} else if (node.name === 'Callout') {
const { heading, icon } = attributes<{ heading?: string; icon: string }>(node);

const child = mdast(heading);
const { icon, empty = false } = attributes<{ empty?: boolean; icon: string }>(node);

// @ts-ignore
const mdNode: Callout = {
children: [child?.children?.[0], ...node.children].filter(Boolean) as any,
children: node.children as any,
type: NodeTypes.callout,
data: {
hName: node.name,
hProperties: { icon },
hProperties: { icon, empty },
},
position: node.position,
};
Expand Down
1 change: 1 addition & 0 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ type Callout = Omit<Blockquote, 'type'> & {
hName: 'Callout';
hProperties: {
icon: string;
empty: boolean;
};
};
};
Expand Down

0 comments on commit 6432be8

Please sign in to comment.