Skip to content

Commit

Permalink
feat: live demo support custom tech stack (#2012)
Browse files Browse the repository at this point in the history
* feat: live demo support custom tech stack

* feat: add live loading status for demo previewer
  • Loading branch information
PeachScript committed Jan 16, 2024
1 parent f09a859 commit 14620d9
Show file tree
Hide file tree
Showing 13 changed files with 205 additions and 105 deletions.
10 changes: 10 additions & 0 deletions src/client/misc/reactDemoCompiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { transform } from 'sucrase';
import type { IDemoCompileFn } from '../theme-api/types';

const compile: IDemoCompileFn = async (code) => {
return transform(code, {
transforms: ['typescript', 'jsx', 'imports'],
}).code;
};

export default compile;
11 changes: 11 additions & 0 deletions src/client/theme-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,20 @@ export type IRoutesById = Record<
}
>;

export type IDemoCompileFn = (
code: string,
opts: { filename: string },
) => Promise<string>;

export type IDemoData = {
component: ComponentType;
asset: IPreviewerProps['asset'];
routeId: string;
context?: Record<string, unknown>;
renderOpts?: {
/**
* provide a runtime compile function for compile demo code for live preview
*/
compile?: IDemoCompileFn;
};
};
69 changes: 50 additions & 19 deletions src/client/theme-api/useLiveDemo.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,64 @@
import { useDemo } from 'dumi';
import throttle from 'lodash.throttle';
import {
createElement,
useCallback,
useRef,
useState,
type ComponentType,
type ReactNode,
} from 'react';
import DemoErrorBoundary from './DumiDemo/DemoErrorBoundary';

const THROTTLE_WAIT = 500;

export const useLiveDemo = (id: string) => {
const { context, asset } = useDemo(id)!;
const { context, asset, renderOpts } = useDemo(id)!;
const [loading, setLoading] = useState(false);
const loadingTimer = useRef<number>();
const [demoNode, setDemoNode] = useState<ReactNode>();
const [error, setError] = useState<Error | null>(null);
const setSource = useCallback(
(source: Record<string, string>) => {
const entryFileName = Object.keys(asset.dependencies).find(
(k) => asset.dependencies[k].type === 'FILE',
)!;
const entryFileCode = source[entryFileName];
const require = (v: string) => {
if (v in context!) return context![v];
throw new Error(`Cannot find module: ${v}`);
};
const exports: { default?: ComponentType } = {};
const module = { exports };
throttle(
async (source: Record<string, string>) => {
// set loading status if still compiling after 499ms
loadingTimer.current = window.setTimeout(
() => {
setLoading(true);
},
// make sure timer be fired before next throttle
THROTTLE_WAIT - 1,
);

const entryFileName = Object.keys(asset.dependencies).find(
(k) => asset.dependencies[k].type === 'FILE',
)!;
const require = (v: string) => {
if (v in context!) return context![v];
throw new Error(`Cannot find module: ${v}`);
};
const exports: { default?: ComponentType } = {};
const module = { exports };
let entryFileCode = source[entryFileName];

// lazy load react-dom/server
import('react-dom/server').then(({ renderToStaticMarkup }) => {
try {
// load renderToStaticMarkup in async way
const renderToStaticMarkupDeferred = import('react-dom/server').then(
({ renderToStaticMarkup }) => renderToStaticMarkup,
);

// compile entry file code
entryFileCode = await renderOpts!.compile!(entryFileCode, {
filename: entryFileName,
});

// initial component with fake runtime
new Function('module', 'exports', 'require', entryFileCode)(
module,
exports,
require,
);

const newDemoNode = createElement(
DemoErrorBoundary,
null,
Expand All @@ -46,8 +71,8 @@ export const useLiveDemo = (id: string) => {
!args[0].includes('useLayoutEffect does nothing on the server') &&
oError.apply(console, args);

// check component is renderable, to avoid show react overlay error
renderToStaticMarkup(newDemoNode);
// check component is able to render, to avoid show react overlay error
(await renderToStaticMarkupDeferred)(newDemoNode);
console.error = oError;

// set new demo node with passing source
Expand All @@ -56,10 +81,16 @@ export const useLiveDemo = (id: string) => {
} catch (err: any) {
setError(err);
}
});
},

// reset loading status
clearTimeout(loadingTimer.current);
setLoading(false);
},
THROTTLE_WAIT,
{ leading: true },
) as (source: Record<string, string>) => Promise<void>,
[context],
);

return { node: demoNode, error, setSource };
return { node: demoNode, loading, error, setSource };
};
8 changes: 5 additions & 3 deletions src/client/theme-api/useSiteSearch/useSearchData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ export default function useSearchData(): [
});

// omit demo component for postmessage
Object.entries(demos).forEach(([id, { component, context, ...demo }]) => {
demos[id] = demo;
});
Object.entries(demos).forEach(
([id, { renderOpts, component, context, ...demo }]) => {
demos[id] = demo;
},
);

setData([mergedRoutes, demos]);
loading.current = false;
Expand Down
54 changes: 43 additions & 11 deletions src/client/theme-default/builtins/Previewer/index.less
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,11 @@
&[data-iframe] {
position: relative;
padding: 0;
overflow: hidden;
border-top: 24px solid @c-border-light;
box-sizing: border-box;

&::before {
content: '';
display: block;
height: 24px;
background-color: @c-border-light;

@{dark-selector} & {
background-color: @c-border-less-dark;
}
@{dark-selector} & {
border-top-color: @c-border-less-dark;
}

&::after {
Expand All @@ -59,7 +53,7 @@

content: '';
position: absolute;
top: 5px;
top: -19px;
left: @btn-gap;
display: inline-block;
width: @btn-width;
Expand Down Expand Up @@ -88,6 +82,44 @@
border-top-right-radius: 3px;
}
}

// loading status
&[data-loading] {
position: relative;

&::before {
@size: 28px;

position: absolute;
top: 50%;
left: 50%;
content: '';
display: block;
height: @size;
max-height: 90%;
aspect-ratio: 1;
border-radius: 50%;
border: (@size / 10) solid;
border-color: @c-primary transparent;
box-sizing: border-box;
animation: dumi-previewer-loading 1s infinite;
transform: translate(-50%, -50%);

@{dark-selector} & {
border-color: @c-primary-dark transparent;
}

@keyframes dumi-previewer-loading {
to {
transform: translate(-50%, -50%) rotate(0.5turn);
}
}
}

> * {
opacity: 0.3 !important;
}
}
}

&-demo-error {
Expand Down
35 changes: 15 additions & 20 deletions src/client/theme-default/builtins/Previewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ReactComponent as IconError } from '@ant-design/icons-svg/inline-svg/fi
import classnames from 'classnames';
import { useLiveDemo, useLocation, type IPreviewerProps } from 'dumi';
import PreviewerActions from 'dumi/theme/slots/PreviewerActions';
import React, { useRef, useState, type FC } from 'react';
import React, { useRef, type FC } from 'react';
import './index.less';

const Previewer: FC<IPreviewerProps> = (props) => {
Expand All @@ -12,10 +12,9 @@ const Previewer: FC<IPreviewerProps> = (props) => {
const {
node: liveDemoNode,
error: liveDemoError,
loading: liveDemoLoading,
setSource: setLiveDemoSource,
} = useLiveDemo(props.asset.id);
const [editorError, setEditorError] = useState<Error | null>(null);
const combineError = liveDemoError || editorError;

return (
<div
Expand All @@ -31,7 +30,8 @@ const Previewer: FC<IPreviewerProps> = (props) => {
data-compact={props.compact || undefined}
data-transform={props.transform || undefined}
data-iframe={props.iframe || undefined}
data-error={Boolean(combineError) || undefined}
data-error={Boolean(liveDemoError) || undefined}
data-loading={liveDemoLoading || undefined}
ref={demoContainer}
>
{props.iframe ? (
Expand All @@ -47,10 +47,10 @@ const Previewer: FC<IPreviewerProps> = (props) => {
liveDemoNode || props.children
)}
</div>
{combineError && (
{liveDemoError && (
<div className="dumi-default-previewer-demo-error">
<IconError />
{combineError.toString()}
{liveDemoError.toString()}
</div>
)}
<div className="dumi-default-previewer-meta">
Expand All @@ -72,21 +72,16 @@ const Previewer: FC<IPreviewerProps> = (props) => {
)}
<PreviewerActions
{...props}
onSourceTranspile={({ err, source }) => {
if (err) {
setEditorError(err);
} else {
setEditorError(null);
setLiveDemoSource(source);
onSourceChange={(source) => {
setLiveDemoSource(source);

if (props.iframe) {
demoContainer
.current!.querySelector('iframe')!
.contentWindow!.postMessage({
type: 'dumi.liveDemo.setSource',
value: source,
});
}
if (props.iframe) {
demoContainer
.current!.querySelector('iframe')!
.contentWindow!.postMessage({
type: 'dumi.liveDemo.setSource',
value: source,
});
}
}}
demoContainer={
Expand Down
45 changes: 25 additions & 20 deletions src/client/theme-default/slots/PreviewerActions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getSketchJSON,
openCodeSandbox,
openStackBlitz,
useDemo,
useIntl,
type IPreviewerProps,
} from 'dumi';
Expand All @@ -31,6 +32,7 @@ export interface IPreviewerActionsProps extends IPreviewerProps {
| { err: Error; source?: null }
| { err?: null; source: Record<string, string> },
) => void;
onSourceChange?: (source: Record<string, string>) => void;
}

const IconCode: FC = () => (
Expand All @@ -57,6 +59,7 @@ const PreviewerActions: FC<IPreviewerActionsProps> = (props) => {
const files = Object.entries(props.asset.dependencies).filter(
([, { type }]) => type === 'FILE',
);
const { renderOpts } = useDemo(props.asset.id)!;
const [activeKey, setActiveKey] = useState(0);
const [showCode, setShowCode] = useState(
props.forceShowCode || props.defaultShowCode,
Expand Down Expand Up @@ -194,18 +197,16 @@ const PreviewerActions: FC<IPreviewerActionsProps> = (props) => {
label: filename.replace(/^\.\//, ''),
// only support to edit entry file currently
children:
i === 0 ? (
i === 0 && renderOpts?.compile ? (
<SourceCodeEditor
lang={lang}
initialValue={files[i][1].value.trim()}
onTranspile={({ err, code }) => {
if (err) {
props.onSourceTranspile?.({ err });
} else {
props.onSourceTranspile?.({
source: { [files[i][0]]: code },
});
}
onChange={(code) => {
props.onSourceChange?.({ [files[i][0]]: code });
// FIXME: remove before publish
props.onSourceTranspile?.({
source: { [files[i][0]]: code },
});
}}
extra={
<button
Expand All @@ -223,17 +224,21 @@ const PreviewerActions: FC<IPreviewerActionsProps> = (props) => {
<SourceCode
lang={lang}
extra={
<button
type="button"
className="dumi-default-previewer-editor-tip-btn"
data-dumi-tooltip={intl.formatMessage({
id: 'previewer.actions.code.readonly',
})}
data-readonly
>
<span></span>
<IconEdit />
</button>
// only show readonly tip for non-entry files
// because readonly entry file means live compile is not available for this demo tech stack
i !== 0 && (
<button
type="button"
className="dumi-default-previewer-editor-tip-btn"
data-dumi-tooltip={intl.formatMessage({
id: 'previewer.actions.code.readonly',
})}
data-readonly
>
<span></span>
<IconEdit />
</button>
)
}
>
{files[activeKey][1].value.trim()}
Expand Down
Loading

0 comments on commit 14620d9

Please sign in to comment.