From f5710896501caebfc04a3b65e02275124d38072c Mon Sep 17 00:00:00 2001 From: PeachScript Date: Sun, 14 Jan 2024 17:04:00 +0800 Subject: [PATCH 1/2] feat: live demo support custom tech stack --- src/client/misc/reactDemoCompiler.ts | 10 +++ src/client/theme-api/types.ts | 11 +++ src/client/theme-api/useLiveDemo.ts | 74 ++++++++++--------- .../theme-api/useSiteSearch/useSearchData.ts | 8 +- .../builtins/Previewer/index.tsx | 33 ++++----- .../slots/PreviewerActions/index.tsx | 45 ++++++----- .../slots/SourceCodeEditor/index.tsx | 34 +-------- src/loaders/markdown/index.ts | 18 ++++- src/loaders/markdown/transformer/index.ts | 8 +- .../markdown/transformer/rehypeDemo.ts | 3 + src/techStacks/react.ts | 4 + src/types.ts | 11 +++ 12 files changed, 151 insertions(+), 108 deletions(-) create mode 100644 src/client/misc/reactDemoCompiler.ts diff --git a/src/client/misc/reactDemoCompiler.ts b/src/client/misc/reactDemoCompiler.ts new file mode 100644 index 0000000000..25eff521be --- /dev/null +++ b/src/client/misc/reactDemoCompiler.ts @@ -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; diff --git a/src/client/theme-api/types.ts b/src/client/theme-api/types.ts index 22574db113..b2d24bf8e3 100644 --- a/src/client/theme-api/types.ts +++ b/src/client/theme-api/types.ts @@ -237,9 +237,20 @@ export type IRoutesById = Record< } >; +export type IDemoCompileFn = ( + code: string, + opts: { filename: string }, +) => Promise; + export type IDemoData = { component: ComponentType; asset: IPreviewerProps['asset']; routeId: string; context?: Record; + renderOpts?: { + /** + * provide a runtime compile function for compile demo code for live preview + */ + compile?: IDemoCompileFn; + }; }; diff --git a/src/client/theme-api/useLiveDemo.ts b/src/client/theme-api/useLiveDemo.ts index 98a55f3223..40c65c4e65 100644 --- a/src/client/theme-api/useLiveDemo.ts +++ b/src/client/theme-api/useLiveDemo.ts @@ -1,4 +1,5 @@ import { useDemo } from 'dumi'; +import throttle from 'lodash.throttle'; import { createElement, useCallback, @@ -9,55 +10,62 @@ import { import DemoErrorBoundary from './DumiDemo/DemoErrorBoundary'; export const useLiveDemo = (id: string) => { - const { context, asset } = useDemo(id)!; + const { context, asset, renderOpts } = useDemo(id)!; const [demoNode, setDemoNode] = useState(); const [error, setError] = useState(null); const setSource = useCallback( - (source: Record) => { + throttle(async (source: Record) => { 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 }; + let entryFileCode = source[entryFileName]; - // lazy load react-dom/server - import('react-dom/server').then(({ renderToStaticMarkup }) => { - try { - // initial component with fake runtime - new Function('module', 'exports', 'require', entryFileCode)( - module, - exports, - require, - ); - const newDemoNode = createElement( - DemoErrorBoundary, - null, - createElement(exports.default!), - ); - const oError = console.error; + try { + // load renderToStaticMarkup in async way + const renderToStaticMarkupDeferred = import('react-dom/server').then( + ({ renderToStaticMarkup }) => renderToStaticMarkup, + ); - // hijack console.error to avoid useLayoutEffect error - console.error = (...args) => - !args[0].includes('useLayoutEffect does nothing on the server') && - oError.apply(console, args); + // compile entry file code + entryFileCode = await renderOpts!.compile!(entryFileCode, { + filename: entryFileName, + }); - // check component is renderable, to avoid show react overlay error - renderToStaticMarkup(newDemoNode); - console.error = oError; + // initial component with fake runtime + new Function('module', 'exports', 'require', entryFileCode)( + module, + exports, + require, + ); - // set new demo node with passing source - setDemoNode(newDemoNode); - setError(null); - } catch (err: any) { - setError(err); - } - }); - }, + const newDemoNode = createElement( + DemoErrorBoundary, + null, + createElement(exports.default!), + ); + const oError = console.error; + + // hijack console.error to avoid useLayoutEffect error + console.error = (...args) => + !args[0].includes('useLayoutEffect does nothing on the server') && + oError.apply(console, args); + + // 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 + setDemoNode(newDemoNode); + } catch (err: any) { + setError(err); + } + }, 500) as (source: Record) => Promise, [context], ); diff --git a/src/client/theme-api/useSiteSearch/useSearchData.ts b/src/client/theme-api/useSiteSearch/useSearchData.ts index 286c7aa7de..c3c369bb11 100644 --- a/src/client/theme-api/useSiteSearch/useSearchData.ts +++ b/src/client/theme-api/useSiteSearch/useSearchData.ts @@ -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; diff --git a/src/client/theme-default/builtins/Previewer/index.tsx b/src/client/theme-default/builtins/Previewer/index.tsx index ce6580bf34..f2b8fc9d9e 100644 --- a/src/client/theme-default/builtins/Previewer/index.tsx +++ b/src/client/theme-default/builtins/Previewer/index.tsx @@ -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 = (props) => { @@ -14,8 +14,6 @@ const Previewer: FC = (props) => { error: liveDemoError, setSource: setLiveDemoSource, } = useLiveDemo(props.asset.id); - const [editorError, setEditorError] = useState(null); - const combineError = liveDemoError || editorError; return (
= (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} ref={demoContainer} > {props.iframe ? ( @@ -47,10 +45,10 @@ const Previewer: FC = (props) => { liveDemoNode || props.children )}
- {combineError && ( + {liveDemoError && (
- {combineError.toString()} + {liveDemoError.toString()}
)}
@@ -72,21 +70,16 @@ const Previewer: FC = (props) => { )} { - 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={ diff --git a/src/client/theme-default/slots/PreviewerActions/index.tsx b/src/client/theme-default/slots/PreviewerActions/index.tsx index 45522a3c66..49fc4b986a 100644 --- a/src/client/theme-default/slots/PreviewerActions/index.tsx +++ b/src/client/theme-default/slots/PreviewerActions/index.tsx @@ -9,6 +9,7 @@ import { getSketchJSON, openCodeSandbox, openStackBlitz, + useDemo, useIntl, type IPreviewerProps, } from 'dumi'; @@ -31,6 +32,7 @@ export interface IPreviewerActionsProps extends IPreviewerProps { | { err: Error; source?: null } | { err?: null; source: Record }, ) => void; + onSourceChange?: (source: Record) => void; } const IconCode: FC = () => ( @@ -57,6 +59,7 @@ const PreviewerActions: FC = (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, @@ -194,18 +197,16 @@ const PreviewerActions: FC = (props) => { label: filename.replace(/^\.\//, ''), // only support to edit entry file currently children: - i === 0 ? ( + i === 0 && renderOpts?.compile ? ( { - 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={ + // 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 && ( + + ) } > {files[activeKey][1].value.trim()} diff --git a/src/client/theme-default/slots/SourceCodeEditor/index.tsx b/src/client/theme-default/slots/SourceCodeEditor/index.tsx index 12649a39d8..4fbb023121 100644 --- a/src/client/theme-default/slots/SourceCodeEditor/index.tsx +++ b/src/client/theme-default/slots/SourceCodeEditor/index.tsx @@ -1,15 +1,12 @@ import SourceCode from 'dumi/theme/builtins/SourceCode'; -import throttle from 'lodash.throttle'; import React, { CSSProperties, - useCallback, useEffect, useRef, useState, type ComponentProps, type FC, } from 'react'; -import type { transform } from 'sucrase'; import './index.less'; interface ISourceCodeEditorProps @@ -18,6 +15,7 @@ interface ISourceCodeEditorProps onTranspile?: ( args: { err: Error; code?: null } | { err?: null; code: string }, ) => void; + onChange?: (code: string) => void; } /** @@ -27,24 +25,6 @@ const SourceCodeEditor: FC = (props) => { const elm = useRef(null); const [style, setStyle] = useState(); const [code, setCode] = useState(props.initialValue); - const sucraseDefer = useRef>(); - const transpile = useCallback( - throttle((value: string) => { - // transform code when change - sucraseDefer.current!.then((transform) => { - try { - props.onTranspile?.({ - code: transform(value, { - transforms: ['typescript', 'jsx', 'imports'], - }).code, - }); - } catch (err: any) { - props.onTranspile?.({ err }); - } - }); - }, 500), - [props.onTranspile], - ); // generate style from pre element, for adapting to the custom theme useEffect(() => { @@ -78,17 +58,11 @@ const SourceCodeEditor: FC = (props) => { className="dumi-default-source-code-editor-textarea" style={style} value={code} - onFocus={() => { - // load sucrase when focus on editor - if (!sucraseDefer.current) { - sucraseDefer.current = import('sucrase').then( - ({ transform }) => transform, - ); - } - }} onChange={(ev) => { setCode(ev.target.value); - transpile(ev.target.value); + props.onChange?.(ev.target.value); + // FIXME: remove before publish + props.onTranspile?.({ err: null, code: ev.target.value }); }} onKeyDown={(ev) => { // support tab to space diff --git a/src/loaders/markdown/index.ts b/src/loaders/markdown/index.ts index 77fc639c6d..696076678c 100644 --- a/src/loaders/markdown/index.ts +++ b/src/loaders/markdown/index.ts @@ -126,7 +126,8 @@ export const demos = { '{{{id}}}': { component: {{{component}}}, asset: {{{renderAsset}}}, - context: {{{renderContext}}} + context: {{{renderContext}}}, + renderOpts: {{{renderRenderOpts}}}, }, {{/demos}} };`, @@ -172,6 +173,21 @@ export const demos = { return JSON.stringify(context, null, 2).replace(/"{{{|}}}"/g, ''); }, + renderRenderOpts: function renderRenderOpts( + this: NonNullable[0], + ) { + if (!('renderOpts' in this) || !this.renderOpts.compilePath) { + return 'undefined'; + } + + return `{ + compile: async (...args) => { + return (await import('${winPath( + this.renderOpts.compilePath, + )}')).default(...args); + }, + }`; + }, }, ); } diff --git a/src/loaders/markdown/transformer/index.ts b/src/loaders/markdown/transformer/index.ts index 1fab3bcc5d..217610db8b 100644 --- a/src/loaders/markdown/transformer/index.ts +++ b/src/loaders/markdown/transformer/index.ts @@ -1,7 +1,12 @@ import type { IParsedBlockAsset } from '@/assetParsers/block'; import type { ILocalesConfig, IRouteMeta } from '@/client/theme-api/types'; import { VERSION_2_DEPRECATE_SOFT_BREAKS } from '@/constants'; -import type { IApi, IDumiConfig, IDumiTechStack } from '@/types'; +import type { + IApi, + IDumiConfig, + IDumiTechStack, + IDumiTechStackRuntimeOpts, +} from '@/types'; import enhancedResolve from 'enhanced-resolve'; import type { IRoute } from 'umi'; import { semver } from 'umi/plugin-utils'; @@ -43,6 +48,7 @@ declare module 'vfile' { component: string; asset: IParsedBlockAsset['asset']; resolveMap: IParsedBlockAsset['resolveMap']; + renderOpts: Pick; } | { id: string; diff --git a/src/loaders/markdown/transformer/rehypeDemo.ts b/src/loaders/markdown/transformer/rehypeDemo.ts index 2a75c38c4a..a24f43ad35 100644 --- a/src/loaders/markdown/transformer/rehypeDemo.ts +++ b/src/loaders/markdown/transformer/rehypeDemo.ts @@ -428,6 +428,9 @@ export default function rehypeDemo( techStackOpts, ) : resolveMap, + renderOpts: { + compilePath: techStack.runtimeOpts?.compilePath, + }, }; }, ), diff --git a/src/techStacks/react.ts b/src/techStacks/react.ts index 8bbbc38757..69dc9b460a 100644 --- a/src/techStacks/react.ts +++ b/src/techStacks/react.ts @@ -4,6 +4,10 @@ import { transformSync } from '@swc/core'; export default class ReactTechStack implements IDumiTechStack { name = 'react'; + runtimeOpts?: IDumiTechStack['runtimeOpts'] = { + compilePath: require.resolve('../client/misc/reactDemoCompiler'), + }; + isSupported(...[, lang]: Parameters) { return ['jsx', 'tsx'].includes(lang); } diff --git a/src/types.ts b/src/types.ts index 5ddfd631ad..b035f0b46c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -63,11 +63,22 @@ export type IDumiUserConfig = Subset> & { [key: string]: any; }; +export interface IDumiTechStackRuntimeOpts { + /** + * path to runtime compile function module + */ + compilePath?: string; +} + export abstract class IDumiTechStack { /** * tech stack name, such as 'react' */ abstract name: string; + /** + * runtime options + */ + abstract runtimeOpts?: IDumiTechStackRuntimeOpts; /** * transform code */ From 595ed08082e797da135adcaa2a5e8f5ae1bc881b Mon Sep 17 00:00:00 2001 From: PeachScript Date: Sun, 14 Jan 2024 20:53:03 +0800 Subject: [PATCH 2/2] feat: add live loading status for demo previewer --- src/client/theme-api/useLiveDemo.ts | 115 +++++++++++------- .../builtins/Previewer/index.less | 54 ++++++-- .../builtins/Previewer/index.tsx | 2 + 3 files changed, 114 insertions(+), 57 deletions(-) diff --git a/src/client/theme-api/useLiveDemo.ts b/src/client/theme-api/useLiveDemo.ts index 40c65c4e65..56cd156730 100644 --- a/src/client/theme-api/useLiveDemo.ts +++ b/src/client/theme-api/useLiveDemo.ts @@ -3,71 +3,94 @@ 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, renderOpts } = useDemo(id)!; + const [loading, setLoading] = useState(false); + const loadingTimer = useRef(); const [demoNode, setDemoNode] = useState(); const [error, setError] = useState(null); const setSource = useCallback( - throttle(async (source: Record) => { - 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]; - - try { - // load renderToStaticMarkup in async way - const renderToStaticMarkupDeferred = import('react-dom/server').then( - ({ renderToStaticMarkup }) => renderToStaticMarkup, + throttle( + async (source: Record) => { + // 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, ); - // compile entry file code - entryFileCode = await renderOpts!.compile!(entryFileCode, { - filename: entryFileName, - }); + 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]; - // initial component with fake runtime - new Function('module', 'exports', 'require', entryFileCode)( - module, - exports, - require, - ); + try { + // load renderToStaticMarkup in async way + const renderToStaticMarkupDeferred = import('react-dom/server').then( + ({ renderToStaticMarkup }) => renderToStaticMarkup, + ); - const newDemoNode = createElement( - DemoErrorBoundary, - null, - createElement(exports.default!), - ); - const oError = console.error; + // 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, + createElement(exports.default!), + ); + const oError = console.error; + + // hijack console.error to avoid useLayoutEffect error + console.error = (...args) => + !args[0].includes('useLayoutEffect does nothing on the server') && + oError.apply(console, args); - // hijack console.error to avoid useLayoutEffect error - console.error = (...args) => - !args[0].includes('useLayoutEffect does nothing on the server') && - oError.apply(console, args); + // check component is able to render, to avoid show react overlay error + (await renderToStaticMarkupDeferred)(newDemoNode); + console.error = oError; - // 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 + setDemoNode(newDemoNode); + setError(null); + } catch (err: any) { + setError(err); + } - // set new demo node with passing source - setDemoNode(newDemoNode); - } catch (err: any) { - setError(err); - } - }, 500) as (source: Record) => Promise, + // reset loading status + clearTimeout(loadingTimer.current); + setLoading(false); + }, + THROTTLE_WAIT, + { leading: true }, + ) as (source: Record) => Promise, [context], ); - return { node: demoNode, error, setSource }; + return { node: demoNode, loading, error, setSource }; }; diff --git a/src/client/theme-default/builtins/Previewer/index.less b/src/client/theme-default/builtins/Previewer/index.less index e92301e828..a835f9f4b8 100644 --- a/src/client/theme-default/builtins/Previewer/index.less +++ b/src/client/theme-default/builtins/Previewer/index.less @@ -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 { @@ -59,7 +53,7 @@ content: ''; position: absolute; - top: 5px; + top: -19px; left: @btn-gap; display: inline-block; width: @btn-width; @@ -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 { diff --git a/src/client/theme-default/builtins/Previewer/index.tsx b/src/client/theme-default/builtins/Previewer/index.tsx index f2b8fc9d9e..00dbfcc202 100644 --- a/src/client/theme-default/builtins/Previewer/index.tsx +++ b/src/client/theme-default/builtins/Previewer/index.tsx @@ -12,6 +12,7 @@ const Previewer: FC = (props) => { const { node: liveDemoNode, error: liveDemoError, + loading: liveDemoLoading, setSource: setLiveDemoSource, } = useLiveDemo(props.asset.id); @@ -30,6 +31,7 @@ const Previewer: FC = (props) => { data-transform={props.transform || undefined} data-iframe={props.iframe || undefined} data-error={Boolean(liveDemoError) || undefined} + data-loading={liveDemoLoading || undefined} ref={demoContainer} > {props.iframe ? (