Skip to content

Commit

Permalink
feat: support for rendering multiple examples in markdown (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
simonguo authored Jul 12, 2022
1 parent d02db0f commit d021789
Show file tree
Hide file tree
Showing 6 changed files with 336 additions and 216 deletions.
64 changes: 64 additions & 0 deletions __tests__/parseHTML-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import parseHTML from '../src/utils/parseHTML';

const trim = str => {
return str.replace(/[\n]+/g, '').trim();
};

it('parse be null', () => {
const result = parseHTML('');

expect(result).toBe(null);
});

it('parse be html', () => {
const result = parseHTML('<html><div></div></html>');

expect(result.length).toBe(1);
expect(result[0].type).toBe('html');
expect(result[0].content).toBe('<html><div></div></html>');
});

it('Parse into one piece of code and two pieces of html', () => {
const html = `<h1>header</h1>
<!--start-code-->
const a = 100;
<!--end-code-->
<p>footer</p>`;

const result = parseHTML(html);

expect(result.length).toBe(3);
expect(result[0].type).toBe('html');
expect(result[1].type).toBe('code');
expect(result[2].type).toBe('html');
expect(trim(result[0].content)).toContain('<h1>header</h1>');
expect(trim(result[1].content)).toContain('const a = 100;');
expect(trim(result[2].content)).toContain('<p>footer</p>');
});

it('Parse into two pieces of code and three pieces of html', () => {
const html = `<h1>header</h1>
<!--start-code-->
const a = 100;
<!--end-code-->
<h2>title</h2>
<!--start-code-->
const b = 200;
<!--end-code-->
<p>footer</p>`;

const result = parseHTML(html);

expect(result.length).toBe(5);
expect(result[0].type).toBe('html');
expect(result[1].type).toBe('code');
expect(result[2].type).toBe('html');
expect(result[3].type).toBe('code');
expect(result[4].type).toBe('html');

expect(trim(result[0].content)).toBe('<h1>header</h1>');
expect(trim(result[1].content)).toBe('const a = 100;');
expect(trim(result[2].content)).toBe('<h2>title</h2>');
expect(trim(result[3].content)).toBe('const b = 200;');
expect(trim(result[4].content)).toBe('<p>footer</p>');
});
14 changes: 13 additions & 1 deletion docs/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ return <CodeView dependencies={{ Button }}>{require('./example.md')}</CodeView>;
## Example

### First example

<!--start-code-->

```js
Expand All @@ -35,12 +37,22 @@ import ReactDOM from 'react-dom';
import { Button } from 'rsuite';

const App = () => {
return <Button>Test</Button>;
return <Button>First example</Button>;
};

ReactDOM.render(<App />);
```

<!--end-code-->

### Second example

<!--start-code-->

```js
ReactDOM.render(<Button>Second example</Button>);
```

<!--end-code-->

> Note: You can try changing the code above and see what changes.
229 changes: 29 additions & 200 deletions src/CodeView.tsx
Original file line number Diff line number Diff line change
@@ -1,89 +1,26 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import { useEffect, useState, useCallback, useRef } from 'react';
import CodeIcon from '@rsuite/icons/Code';
import classNames from 'classnames';
import React from 'react';
import MarkdownRenderer from './MarkdownRenderer';
import CodeEditor from './CodeEditor';
import parseHTML from './utils/parseHTML';
import Preview from './Preview';
import canUseDOM from './utils/canUseDOM';

const React = require('react');
const ReactDOM = require('react-dom');

export interface CodeViewProps extends Omit<React.HTMLAttributes<HTMLElement>, 'onChange'> {
/** Code editor theme, applied to CodeMirror */
theme?: 'light' | 'dark';
import Renderer, { RendererProps } from './Renderer';

export interface CodeViewProps extends RendererProps {
/** The code to be rendered is executed. Usually imported via markdown-loader. */
children?: any;

/** The code to be rendered is executed */
sourceCode?: string;

/** Dependent objects required by the executed code */
dependencies?: object;

/** Renders a code editor that can modify the source code */
editable?: boolean;

/** Editor properties */
editor?: {
className?: string;

/** Add a prefix to the className of the buttons on the toolbar */
classPrefix?: string;

/** The className of the code button displayed on the toolbar */
buttonClassName?: string;

/** Customize the code icon on the toolbar */
icon?: React.ReactNode;
};

/**
* swc configuration
* https://swc.rs/docs/configuration/compilation
*/
transformOptions?: object;

/** Customize the rendering toolbar */
renderToolbar?: (buttons: React.ReactNode) => React.ReactNode;

/** Callback triggered after code change */
onChange?: (code?: string) => void;

/**
* A compiler that transforms the code. Use swc.transformSync by default
* See https://swc.rs/docs/usage/wasm
*/
compiler?: (code: string) => string;

/** Executed before compiling the code */
beforeCompile?: (code: string) => string;

/** Executed after compiling the code */
afterCompile?: (code: string) => string;
}

const defaultTransformOptions = {
jsc: {
parser: {
syntax: 'ecmascript',
jsx: true
}
}
};

const CodeView = React.forwardRef((props: CodeViewProps, ref: React.Ref<HTMLDivElement>) => {
const {
children,
sourceCode,
dependencies,
editor = {},
theme = 'light',
editable: isEditable = false,
transformOptions = defaultTransformOptions,
sourceCode,
editable,
transformOptions,
renderToolbar,
onChange,
beforeCompile,
Expand All @@ -92,141 +29,33 @@ const CodeView = React.forwardRef((props: CodeViewProps, ref: React.Ref<HTMLDivE
...rest
} = props;

const {
classPrefix,
icon: codeIcon,
className: editorClassName,
buttonClassName,
...editorProps
} = editor;

const [initialized, setInitialized] = useState(false);
const transfrom = useRef<any>(null);

useEffect(() => {
if (!canUseDOM) {
return;
}

import('@swc/wasm-web').then(async module => {
await module.default();
transfrom.current = module.transformSync;
setInitialized(true);
});
}, []);

const sourceStr: string = children?.__esModule ? children.default : sourceCode;
const { code, beforeHTML, afterHTML } = parseHTML(sourceStr) || {};
const [editable, setEditable] = useState(isEditable);
const [hasError, setHasError] = useState(false);
const [errorMessage, setErrorMessage] = useState(null);
const [compiledReactNode, setCompiledReactNode] = useState(null);

const handleExpandEditor = useCallback(() => {
setEditable(!editable);
}, [editable]);

const handleError = useCallback(error => {
setHasError(true);
setErrorMessage(error.message);
}, []);

const prefix = name => (classPrefix ? `${classPrefix}-${name}` : name);

const executeCode = useCallback(
(pendCode: string = code) => {
if (!canUseDOM) {
return;
}

const originalRender = ReactDOM.render;

// Redefine the render function, which will reset to the default value after `eval` is executed.
ReactDOM.render = element => {
setCompiledReactNode(element);
};

try {
const statement = dependencies
? Object.keys(dependencies).map(key => `var ${key}= dependencies.${key};`)
: [];

const beforeCompileCode = beforeCompile?.(pendCode) || pendCode;

if (beforeCompileCode) {
const { code: compiledCode } = compiler
? compiler(beforeCompileCode)
: transfrom.current?.(beforeCompileCode, transformOptions);

eval(`${statement.join('\n')} ${afterCompile?.(compiledCode) || compiledCode}`);
}
} catch (err) {
console.error(err);
} finally {
// Reset the render function to the original value.
ReactDOM.render = originalRender;
}
},
[code, dependencies, beforeCompile, compiler, transformOptions, afterCompile]
);

useEffect(() => {
if (initialized) {
executeCode(code);
}
}, [initialized, code, executeCode]);

const handleCodeChange = useCallback(
(code?: string) => {
setHasError(false);
setErrorMessage(null);
onChange?.(code);

if (initialized) {
executeCode(code);
}
},
[executeCode, initialized, onChange]
);

const codeButton = (
<button
role="switch"
aria-checked={editable}
aria-label="Show the full source"
className={classNames(prefix('btn'), prefix('btn-xs'), buttonClassName)}
onClick={handleExpandEditor}
>
{typeof codeIcon !== 'undefined' ? (
codeIcon
) : (
<CodeIcon className={classNames(prefix('icon'), prefix('icon-code'))} />
)}
</button>
);

const showCodeEditor = editable && code && initialized;
const fragments = parseHTML(sourceStr);

return (
<div ref={ref} {...rest}>
<MarkdownRenderer>{beforeHTML}</MarkdownRenderer>
<div className="rcv-container">
<Preview hasError={hasError} errorMessage={errorMessage} onError={handleError}>
{compiledReactNode}
</Preview>
<div className="rcv-toolbar">{renderToolbar ? renderToolbar(codeButton) : codeButton}</div>
{showCodeEditor && (
<CodeEditor
{...editorProps}
key="jsx"
onChange={handleCodeChange}
className={classNames(editorClassName, 'rcv-editor')}
editorConfig={{ lineNumbers: true, theme: `base16-${theme}` }}
code={code}
/>
)}
</div>
<MarkdownRenderer>{afterHTML}</MarkdownRenderer>
{fragments?.map(fragment => {
if (fragment.type === 'code') {
return (
<Renderer
key={fragment.key}
code={fragment.content}
editable={editable}
theme={theme}
dependencies={dependencies}
transformOptions={transformOptions}
renderToolbar={renderToolbar}
onChange={onChange}
beforeCompile={beforeCompile}
compiler={compiler}
afterCompile={afterCompile}
editor={editor}
/>
);
} else if (fragment.type === 'html') {
return <MarkdownRenderer key={fragment.key}>{fragment.content}</MarkdownRenderer>;
}
})}
</div>
);
});
Expand Down
Loading

0 comments on commit d021789

Please sign in to comment.