Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for simplified interactive demo syntax using code blocks #7907

Merged
merged 10 commits into from
Jul 26, 2024
Merged
26 changes: 26 additions & 0 deletions .yarn/patches/react-live-npm-4.1.7-7b41625faa.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
diff --git a/dist/index.js b/dist/index.js
index 7bcfee33d9cfaa6c5f9d7da40335ec2551f932b6..4c29438cb94412d25aed193f9c86f20ccebb9009 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -168,7 +168,7 @@ var import_sucrase = require("sucrase");
var defaultTransforms = ["jsx", "imports"];
function transform(opts = {}) {
const transforms = Array.isArray(opts.transforms) ? opts.transforms.filter(Boolean) : defaultTransforms;
- return (code) => (0, import_sucrase.transform)(code, { transforms }).code;
+ return (code) => (0, import_sucrase.transform)(code, { transforms, jsxPragma: 'jsx' }).code;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed temporarily because react-live doesn't support passing arguments to sucrase (the client-side JS/TS transpiler). I'm planning to add this functionality to react-live in a PR after we finish this milestone.

Defining jsxPragma here configures sucrase to emit jsx([...]) instead of the regular React.createElement([...]) and control what the jsx is - in our case it's the Emotion jsx wrapper. We inject the custom jsx here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So awesome this patching functionality!

}

// src/utils/transpile/errorBoundary.tsx
diff --git a/dist/index.mjs b/dist/index.mjs
index 44a3fbed4fc0545b75235add7369a262e8ee5220..6cfad17b4ce81553bac1243b7421f5f75ade43fc 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -127,7 +127,7 @@ import { transform as _transform } from "sucrase";
var defaultTransforms = ["jsx", "imports"];
function transform(opts = {}) {
const transforms = Array.isArray(opts.transforms) ? opts.transforms.filter(Boolean) : defaultTransforms;
- return (code) => _transform(code, { transforms }).code;
+ return (code) => _transform(code, { transforms, jsxPragma: 'jsx' }).code;
}

// src/utils/transpile/errorBoundary.tsx
2 changes: 1 addition & 1 deletion packages/docusaurus-theme/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"moment": "^2.30.1",
"prism-react-renderer": "^2.3.1",
"react-is": "^18.3.1",
"react-live": "^4.1.7"
"react-live": "patch:react-live@npm%3A4.1.7#~/.yarn/patches/react-live-npm-4.1.7-7b41625faa.patch"
},
"peerDependencies": {
"react": "^18.0.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/docusaurus-theme/src/components/demo/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { createContext, useContext } from 'react';
import { DemoSourceMeta } from './demo';

export interface DemoContextObject {
/**
* Array of all available sources for this demo instance
*/
sources: DemoSourceMeta[];

/**
* Add source to the list of available sources
* This should only be used internally when initializing the component!
*/
addSource(source: DemoSourceMeta): void;
}

Expand Down
9 changes: 6 additions & 3 deletions packages/docusaurus-theme/src/components/demo/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { DemoSource } from './source';
import { demoDefaultScope } from './scope';
import { DemoActionsBar } from './actions_bar';
import { demoCodeTransformer } from './code_transformer';
import { DemoPreviewProps } from './preview/preview';

export interface DemoSourceMeta {
code: string;
Expand All @@ -40,6 +41,7 @@ export interface DemoProps extends PropsWithChildren {
* The default scope exposes all React and EUI exports.
*/
scope?: Record<string, unknown>;
previewPadding?: DemoPreviewProps['padding'];
}

const getDemoStyles = (euiTheme: UseEuiTheme) => ({
Expand All @@ -56,12 +58,13 @@ const getDemoStyles = (euiTheme: UseEuiTheme) => ({
export const Demo = ({
children,
scope,
isSourceOpen: _isSourceOpen = true
isSourceOpen: _isSourceOpen = false,
previewPadding,
}: DemoProps) => {
const styles = useEuiMemoizedStyles(getDemoStyles);
const [sources, setSources] = useState<DemoSourceMeta[]>([]);
const [isSourceOpen, setIsSourceOpen] = useState<boolean>(_isSourceOpen);
const activeSource = sources[0];
const activeSource = sources[0] || null;

// liveProviderKey restarts the demo to its initial state
const [liveProviderKey, setLiveProviderKey] = useState<number>(0);
Expand Down Expand Up @@ -101,7 +104,7 @@ export const Demo = ({
theme={prismThemes.dracula}
scope={finalScope}
>
<DemoPreview />
<DemoPreview padding={previewPadding} />
<DemoActionsBar
isSourceOpen={isSourceOpen}
setSourceOpen={setIsSourceOpen}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { LiveEditor, LiveError } from 'react-live';
import { css } from '@emotion/react';
import { useEuiMemoizedStyles, useEuiTheme } from '@elastic/eui';
import { useEuiMemoizedStyles } from '@elastic/eui';

const getEditorStyles = () => ({
editor: css`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@ import { LivePreview } from 'react-live';
import BrowserOnly from '@docusaurus/BrowserOnly';
import ErrorBoundary from '@docusaurus/ErrorBoundary';
import { ErrorBoundaryErrorMessageFallback } from '@docusaurus/theme-common';
import { UseEuiTheme, EuiFlexGroup, useEuiTheme } from '@elastic/eui';
import { UseEuiTheme, useEuiTheme, EuiPaddingSize, euiPaddingSize } from '@elastic/eui';
import { CSSProperties } from 'react';

export interface DemoPreviewProps {
padding?: EuiPaddingSize;
}

const getPreviewStyles = (euiTheme: UseEuiTheme) => ({
previewWrapper: css`
padding: ${euiTheme.euiTheme.size.l};
padding: var(--docs-demo-preview-padding);
border-radius: var(--docs-demo-border-radius);
`,
});
Expand All @@ -21,16 +26,21 @@ const PreviewLoader = () => (
<div>Loading...</div>
);

export const DemoPreview = () => {
export const DemoPreview = ({ padding = 'l' }: DemoPreviewProps) => {
const euiTheme = useEuiTheme();
const styles = getPreviewStyles(euiTheme);
const paddingSize = euiPaddingSize(euiTheme, padding);

const style = {
'--docs-demo-preview-padding': paddingSize,
} as CSSProperties;
Comment on lines +34 to +36
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing this with a CSS variable is technically more performant than recomputing and reapplying the whole style with Emotion. Not that it matters here, but I was experimenting with the approaches of passing dynamic data to CSS without Emotion and really liked this solution.

There are many optimizations browsers could do with this to reduce style recalculations when the value changes. It would be faster to define the property using element.style.setProperty but it's more painful to maintain, so yeah, inline styles it is.


return (
<BrowserOnly fallback={<PreviewLoader />}>
{() => (
<>
<ErrorBoundary fallback={(params: any) => <ErrorBoundaryErrorMessageFallback {...params} />}>
<div css={styles.previewWrapper}>
<div css={styles.previewWrapper} style={style}>
<LivePreview />
</div>
</ErrorBoundary>
Expand Down
14 changes: 14 additions & 0 deletions packages/docusaurus-theme/src/components/demo/scope.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import React from 'react';
import * as EUI from '@elastic/eui';
import * as EmotionReact from '@emotion/react';

/**
* A custom client-side require() alternative to inform users it's not available
* in our demo environment
*/
const clientSideRequire = () => {
throw new Error('require() is not accessible in the interactive demo environment! All EUI and React exports are available in the global scope for you to use without the need to import them.');
}

export const demoDefaultScope: Record<string, unknown> = {
// React
Expand All @@ -8,4 +17,9 @@ export const demoDefaultScope: Record<string, unknown> = {

// EUI exports
...EUI,

// Emotion
...EmotionReact,

require: clientSideRequire,
};
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface SourceMeta {
* Get source string from given children.
*/
export const getSourceFromChildren = (children: ReactNode): string | null => {
// Direct (non-MDX) usage almost always passes a string
if (typeof children === 'string') {
return children;
}

if (Children.count(children) !== 1 || !isElement(children)) {
// This should never happen
return null;
Expand Down
44 changes: 44 additions & 0 deletions packages/docusaurus-theme/src/theme/CodeBlock/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React, { isValidElement, type ReactNode } from 'react';
import { EuiCodeBlock } from '@elastic/eui';
import type { Props } from '@theme/CodeBlock';
import { Demo } from '../../components/demo';

/**
* Best attempt to make the children a plain string so it is copyable. If there
* are react elements, we will not be able to copy the content, and it will
* return `children` as-is; otherwise, it concatenates the string children
* together.
*/
function maybeStringifyChildren(children: ReactNode): ReactNode {
if (React.Children.toArray(children).some((el) => isValidElement(el))) {
return children;
}
// The children is now guaranteed to be one/more plain strings
return Array.isArray(children) ? children.join('') : (children as string);
}

export default function CodeBlock({
children: rawChildren,
metastring,
className,
...props
}: Props): JSX.Element {
const children = maybeStringifyChildren(rawChildren);
const language = className?.replace('language-', '') || undefined;

if (metastring?.startsWith('interactive')) {
return <Demo {...props}>{children}</Demo>;
}

return (
<EuiCodeBlock
{...props}
fontSize="m"
overflowHeight={450}
language={language}
isCopyable
>
{children}
</EuiCodeBlock>
);
}
33 changes: 19 additions & 14 deletions packages/docusaurus-theme/src/theme/MDXComponents/Code.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import React from 'react';
import type CodeType from '@theme-init/MDXComponents/Code';
import CodeBlock from '@theme/CodeBlock';
import type { WrapperProps } from '@docusaurus/types';
import { css } from '@emotion/react';
import {
EuiCode,
EuiCodeBlock,
useEuiMemoizedStyles,
UseEuiTheme,
} from '@elastic/eui';
import { EuiCode, useEuiMemoizedStyles, UseEuiTheme } from '@elastic/eui';

type Props = WrapperProps<typeof CodeType>;

Expand All @@ -33,15 +29,24 @@ const Code = ({ children, className, ...rest }: Props): JSX.Element => {
)
: false;

return isInlineCode ? (
<EuiCode {...rest} language={language} css={styles.code}>
{children}
</EuiCode>
) : (
<EuiCodeBlock {...rest} fontSize="m" language={language}>
if (isInlineCode) {
return (
<EuiCode
{...rest}
className={className}
language={language}
css={styles.code}
>
{children}
</EuiCode>
);
}

return (
<CodeBlock className={className} {...rest}>
{children}
</EuiCodeBlock>
);
</CodeBlock>
)
};

export default Code;
25 changes: 25 additions & 0 deletions packages/docusaurus-theme/src/theme/theme.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,31 @@ declare module '@theme-original/EditThisPage' {
export default function EditThisPage(props: Props): JSX.Element;
}

// original: https://github.com/facebook/docusaurus/blob/fa743c81defd24e22eae45c81bd79eb8ec2c4ef0/packages/docusaurus-theme-classic/src/theme-classic.d.ts#L364
declare module '@theme/CodeBlock' {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❓ So far I've used the @theme-original alias to align with other imports as we import everything else from @theme-original when swizzled. Do you think that makes sense and should we update this here too?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I specifically used @theme/CodeBlock to access our swizzled CodeBlock component or any other higher-level swizzled CodeBlock in an additional theme package or in website's src/theme directory. This makes it more reusable and customizable for other users.

Here's the documentation on differences between @theme-init, @theme-original, and @theme - https://docusaurus.io/docs/advanced/client

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for the types we could update the other cases that I added with @theme-original to @theme too eventually as they are just adding the types that are not available when swizzled.
But I think it there is no hurry for this.

import type { ReactNode } from 'react';

export interface Props {
readonly children: ReactNode;
readonly className?: string;
readonly metastring?: string;
readonly title?: string;
readonly language?: string;
readonly showLineNumbers?: boolean;
}

export default function CodeBlock(props: Props): JSX.Element;
}

// original: https://github.com/facebook/docusaurus/blob/8b877d27d4b1bcd5c2ee13dde8332407a1c26447/packages/docusaurus-theme-classic/src/theme-classic.d.ts#L510
declare module '@theme/MDXComponents/Code' {
import type {ComponentProps} from 'react';

export interface Props extends ComponentProps<'code'> {}

export default function MDXCode(props: Props): JSX.Element;
}

// original: https://github.com/facebook/docusaurus/blob/fa743c81defd24e22eae45c81bd79eb8ec2c4ef0/packages/docusaurus-theme-classic/src/theme-classic.d.ts#L563
declare module '@theme-original/DocSidebar' {
import type { PropSidebarItem } from '@docusaurus/plugin-content-docs';
Expand Down
Loading
Loading