diff --git a/examples/cra-example/react-showroom.config.js b/examples/cra-example/react-showroom.config.js index 95d4a024..227ff312 100644 --- a/examples/cra-example/react-showroom.config.js +++ b/examples/cra-example/react-showroom.config.js @@ -9,4 +9,7 @@ module.exports = defineConfig({ theme: { title: 'CRA 5 Example', }, + experiments: { + interactions: true, + }, }); diff --git a/examples/cra-example/src/components/button.md b/examples/cra-example/src/components/button.mdx similarity index 64% rename from examples/cra-example/src/components/button.md rename to examples/cra-example/src/components/button.mdx index 5772e1ef..33a90d05 100644 --- a/examples/cra-example/src/components/button.md +++ b/examples/cra-example/src/components/button.mdx @@ -1,3 +1,5 @@ +import { InteractionBlock } from 'react-showroom/client'; + ```tsx frame
@@ -5,3 +7,5 @@
``` + + diff --git a/examples/cra-example/src/components/button.spec.tsx b/examples/cra-example/src/components/button.spec.tsx new file mode 100644 index 00000000..eeb83222 --- /dev/null +++ b/examples/cra-example/src/components/button.spec.tsx @@ -0,0 +1,13 @@ +import { render, screen } from '@testing-library/react'; +import { Button } from './button'; + +test('Button defined', () => { + expect(Button).toBeDefined(); +}); + +describe('); + expect(screen.getByText('Hello')).toBeVisible(); + }); +}); diff --git a/examples/react-example/src/components/button.spec.tsx b/examples/react-example/src/components/button.spec.tsx index 333b0ea2..7b3a99c0 100644 --- a/examples/react-example/src/components/button.spec.tsx +++ b/examples/react-example/src/components/button.spec.tsx @@ -3,6 +3,6 @@ import { Button } from './button'; describe('); + render(); }); }); diff --git a/packages/core/src/acorn-ast.ts b/packages/core/src/acorn-ast.ts new file mode 100644 index 00000000..8bac3992 --- /dev/null +++ b/packages/core/src/acorn-ast.ts @@ -0,0 +1,57 @@ +import type { Node } from 'acorn'; + +export interface LiteralNode extends Node { + type: 'Literal'; + value: string; + raw: string; +} + +export interface IdentifierNode extends Node { + type: 'Identifier'; + name: string; +} + +export interface ImportSpecifierNode extends Node { + type: 'ImportSpecifier'; + imported: IdentifierNode; + local: IdentifierNode; +} + +export interface ImportNamespaceSpecifierNode extends Node { + type: 'ImportNamespaceSpecifier'; + local: IdentifierNode; +} + +export interface ImportDefaultSpecifierNode extends Node { + type: 'ImportDefaultSpecifier'; + local: IdentifierNode; +} + +export interface ImportDeclarationNode extends Node { + type: 'ImportDeclaration'; + source: LiteralNode; + specifiers: Array< + | ImportSpecifierNode + | ImportNamespaceSpecifierNode + | ImportDefaultSpecifierNode + >; +} + +export interface MemberExpressionNode extends Node { + type: 'MemberExpression'; + object: IdentifierNode; + property: IdentifierNode; +} + +export interface ExpressionStatementNode extends Node { + type: 'ExpressionStatement'; + expression: { + arguments: Array; + callee: Node; + }; +} + +export interface ProgramNode extends Node { + type: 'Program'; + body: Array; +} diff --git a/packages/core/src/compile-script.ts b/packages/core/src/compile-script.ts index 4fbae0bc..26ce72ca 100644 --- a/packages/core/src/compile-script.ts +++ b/packages/core/src/compile-script.ts @@ -1,8 +1,17 @@ import { Node, Options, parse } from 'acorn'; import * as walk from 'acorn-walk'; -import { getSafeName } from './get-safe-name'; import type * as esbuild from 'esbuild'; +import type { + ExpressionStatementNode, + ImportDeclarationNode, + ImportDefaultSpecifierNode, + ImportNamespaceSpecifierNode, + ImportSpecifierNode, + MemberExpressionNode, + ProgramNode, +} from './acorn-ast'; import { ReactShowroomFeatureCompilation } from './compilation'; +import { getSafeName } from './get-safe-name'; export interface ImportMapData { name: string; @@ -136,62 +145,6 @@ const insertRenderIfEndWithJsx = (code: string): string => { const hasImports = (code: string): boolean => !!code.match(/import[\S\s]+?['"]([^'"]+)['"];?/m); -interface LiteralNode extends Node { - type: 'Literal'; - value: string; - raw: string; -} - -interface IdentifierNode extends Node { - type: 'Identifier'; - name: string; -} - -interface ImportSpecifierNode extends Node { - type: 'ImportSpecifier'; - imported: IdentifierNode; - local: IdentifierNode; -} - -interface ImportNamespaceSpecifierNode extends Node { - type: 'ImportNamespaceSpecifier'; - local: IdentifierNode; -} - -interface ImportDefaultSpecifierNode extends Node { - type: 'ImportDefaultSpecifier'; - local: IdentifierNode; -} - -interface ImportDeclarationNode extends Node { - type: 'ImportDeclaration'; - source: LiteralNode; - specifiers: Array< - | ImportSpecifierNode - | ImportNamespaceSpecifierNode - | ImportDefaultSpecifierNode - >; -} - -interface MemberExpressionNode extends Node { - type: 'MemberExpression'; - object: IdentifierNode; - property: IdentifierNode; -} - -interface ExpressionStatementNode extends Node { - type: 'ExpressionStatement'; - expression: { - arguments: Array; - callee: Node; - }; -} - -interface ProgramNode extends Node { - type: 'Program'; - body: Array; -} - const isExpressionNode = (node: Node): node is ExpressionStatementNode => node.type === 'ExpressionStatement'; const isMemberExpressionNode = (node: Node): node is MemberExpressionNode => diff --git a/packages/core/src/compile-tests.ts b/packages/core/src/compile-tests.ts new file mode 100644 index 00000000..a7ee772d --- /dev/null +++ b/packages/core/src/compile-tests.ts @@ -0,0 +1,138 @@ +import { Node, Options as AcornOptions, parse } from 'acorn'; +import * as walk from 'acorn-walk'; +import type { ImportDeclarationNode } from './acorn-ast'; +import { stringToIdentifier } from './get-safe-name'; + +const ACORN_OPTIONS: AcornOptions = { + ecmaVersion: 2018, + sourceType: 'module', +}; + +export const compileTestsToMap = (source: string) => { + const testsMap: Record = {}; + + let code = source; + + const ast = parse(code, ACORN_OPTIONS); + + let offset = 0; + + walk.simple(ast, { + CallExpression: (callNode) => { + const node = callNode as CallExpressionNode; + if (node.callee.name === 'test' || node.callee.name === 'it') { + const name = node.arguments[0].value; + + testsMap[name] = stringToIdentifier(name); + + code = + code.substring(0, node.start + offset) + + code.substring(node.end + offset); + offset -= node.end - node.start; + } + }, + }); + + return `export default {${Object.entries(testsMap) + .map(([fnName, encodedName]) => `['${fnName}']: '${encodedName}',`) + .join('\n')} + };`; +}; + +export const compileTests = (source: string) => { + const imports: Array = [ + `import { jest, expect } from 'react-showroom/client-dist/lib/test-globals.js';`, + ]; + let hasImportedReact = false; + + const testsMap: Record = {}; + + let code = source; + + const ast = parse(code, ACORN_OPTIONS); + + let offset = 0; + + walk.simple(ast, { + ImportDeclaration: (n) => { + const node = n as ImportDeclarationNode; + const start = node.start + offset; + const end = node.end + offset; + + if (node.source.value === 'react') { + hasImportedReact = true; + } + + const statement = code.substring(start, end); + imports.push(statement); + + code = code.substring(0, start) + code.substring(end); + offset -= statement.length; + }, + CallExpression: (callNode) => { + const node = callNode as CallExpressionNode; + if (node.callee.name === 'test' || node.callee.name === 'it') { + const name = node.arguments[0].value; + const testCode = node.arguments[1]; + + testsMap[stringToIdentifier(name)] = code.substring( + testCode.start + offset, + testCode.end + offset + ); + + code = + code.substring(0, node.start + offset) + + code.substring(node.end + offset); + offset -= node.end - node.start; + } + }, + }); + + return `${(hasImportedReact + ? imports + : imports.concat(`import * as React from 'react';`) + ).join('\n')} + ${stripDescribe(code)} + ${Object.entries(testsMap) + .map( + ([fnName, fnExpression]) => + `export const ${fnName} = async ${fnExpression};` + ) + .join('\n')}`; +}; + +function stripDescribe(source: string) { + let code = source; + + const ast = parse(code, { + ecmaVersion: 2018, + sourceType: 'module', + }); + + let offset = 0; + + walk.simple(ast, { + CallExpression: (callNode) => { + const node = callNode as CallExpressionNode; + if (node.callee.name === 'describe') { + code = + code.substring(0, node.start + offset) + + code.substring(node.end + offset); + offset -= node.end - node.start; + } + }, + }); + + return code; +} + +interface CallExpressionNode extends Node { + callee: { + name: string; + } & Node; + arguments: Array< + { + value: string; + } & Node + >; +} diff --git a/packages/core/src/get-safe-name.spec.ts b/packages/core/src/get-safe-name.spec.ts index 06e8de39..980bb83d 100644 --- a/packages/core/src/get-safe-name.spec.ts +++ b/packages/core/src/get-safe-name.spec.ts @@ -1,4 +1,4 @@ -import { getSafeName } from './get-safe-name'; +import { getSafeName, stringToIdentifier } from './get-safe-name'; test('getSafeName', () => { expect(getSafeName('')).toBe(''); @@ -6,3 +6,16 @@ test('getSafeName', () => { expect(getSafeName('react-query')).toBe('reactQuery'); expect(getSafeName('@org/ui-lib')).toBe('org__uiLib'); }); + +test('stringToIdentifier', () => { + expect(stringToIdentifier('I have a dream')).toBe('_IHaveADream'); + expect(stringToIdentifier('1st test')).toBe('_1stTest'); + expect(stringToIdentifier('$ I love')).toBe('_$ILove'); + expect(stringToIdentifier('_(nice)')).toBe('__nice'); + expect( + stringToIdentifier(`I + love 1 and + 3 $ + `) + ).toBe('_ILove1And3$'); +}); diff --git a/packages/core/src/get-safe-name.ts b/packages/core/src/get-safe-name.ts index 2ceff0b5..748798dc 100644 --- a/packages/core/src/get-safe-name.ts +++ b/packages/core/src/get-safe-name.ts @@ -10,3 +10,15 @@ export const getSafeName = (pkgName: string): string => { .replace(/\//, '__') .replace(/[^a-zA-Z\_]/g, ''); }; + +export const stringToIdentifier = (oriString: string): string => { + if (!oriString) { + return oriString; + } + + return ('_' + oriString) + .replace(/(\s)([a-z])/g, function (_, __, x) { + return x.toUpperCase(); + }) + .replace(/[^A-Za-z0-9_$]/g, ''); +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9bdf14cc..af152176 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export { SUPPORTED_LANGUAGES, } from './compilation'; export { compileScript, ImportMapData, Packages } from './compile-script'; +export { compileTests, compileTestsToMap } from './compile-tests'; export { dedupeArray } from './dedupe-array'; export { deviceDimensions, @@ -18,7 +19,7 @@ export { decodeDisplayName, encodeDisplayName } from './display-name'; export { flattenArray, NestedArray } from './flatten-array'; export { callAll, noop } from './fn-lib'; export type { Callback } from './fn-lib'; -export { getSafeName } from './get-safe-name'; +export { getSafeName, stringToIdentifier } from './get-safe-name'; export { isEqualArray } from './is-equal-array'; export { omit, pick } from './object'; export { compileHtml } from './process-html'; diff --git a/packages/core/src/react.ts b/packages/core/src/react.ts index 32276e84..e0da0544 100644 --- a/packages/core/src/react.ts +++ b/packages/core/src/react.ts @@ -234,6 +234,10 @@ export interface ShowroomSearchConfiguration { includeHeadings: boolean; } +export interface ShowroomExperimentsConfiguration { + interactions: boolean; +} + export interface ReactShowroomConfiguration { /** * URL for the site. @@ -345,12 +349,14 @@ export interface ReactShowroomConfiguration { */ cacheDir?: string; debug?: boolean; + experiments?: Partial; } export interface ReactShowroomComponentSectionConfig { type: 'component'; sourcePath: string; docPath: string | null; + testPath: string | null; parentSlugs: Array; id: string; hideFromSidebar?: boolean; @@ -428,6 +434,7 @@ export interface NormalizedReactShowroomConfiguration * typescript compiler options to be used in advanced code editor */ compilerOptions: Partial; + experiments: ShowroomExperimentsConfiguration; } export interface ReactShowroomComponentContent { @@ -437,6 +444,7 @@ export interface ReactShowroomComponentContent { imports: Record; codeblocks: CodeBlocks; loadDts: () => Promise<{ default: Record }>; + testMap?: Record; } export interface ComponentDocItem { diff --git a/packages/react-showroom/client/app/interaction-app.tsx b/packages/react-showroom/client/app/interaction-app.tsx new file mode 100644 index 00000000..d138b12d --- /dev/null +++ b/packages/react-showroom/client/app/interaction-app.tsx @@ -0,0 +1,71 @@ +import { useQuery } from '@showroomjs/bundles/query'; +import { listenForConsole } from '@showroomjs/ui'; +import * as React from 'react'; +import testMap from 'react-showroom-tests'; +import Wrapper from 'react-showroom-wrapper'; +import { ErrorPage } from '../components/error-page'; +import { useInteractionWindow } from '../lib/frame-message'; +import { Route, Routes, useParams } from '../lib/routing'; +import { useHeightChange } from '../lib/use-height-change'; + +export const InteractionApp = () => { + return ( + + + } /> + } /> + + + ); +}; + +const InteractionPage = () => { + const params = useParams<{ + componentId: string; + testId: string; + }>(); + + useQuery( + ['interactions', params.componentId], + () => { + const getFn = params.componentId && testMap[params.componentId]; + + return getFn ? getFn() : undefined; + }, + { + onSuccess: (data) => { + if (data && params.testId && data[params.testId]) { + data[params.testId](); + } + }, + enabled: !!params.componentId, + } + ); + + const { sendParent } = useInteractionWindow(() => {}); + + useHeightChange( + typeof window === 'undefined' ? null : window.document.documentElement, + (height) => + sendParent({ + type: 'heightChange', + height, + }) + ); + + React.useEffect(() => { + const { cleanup, events } = listenForConsole(); + + events.on('invoke', (ev) => { + sendParent({ + type: 'log', + level: ev.method, + data: ev.data, + }); + }); + + return cleanup; + }, []); + + return null; +}; diff --git a/packages/react-showroom/client/app/interaction-client-entry.tsx b/packages/react-showroom/client/app/interaction-client-entry.tsx new file mode 100644 index 00000000..e34b977b --- /dev/null +++ b/packages/react-showroom/client/app/interaction-client-entry.tsx @@ -0,0 +1,20 @@ +import { QueryClientProvider } from '@showroomjs/bundles/query'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { basename, isPrerender } from '../lib/config'; +import { createQueryClient } from '../lib/create-query-client'; +import { BrowserRouter as Router } from '../lib/routing'; +import { InteractionApp } from './interaction-app'; + +const queryClient = createQueryClient(); + +const render = isPrerender ? ReactDOM.hydrate : ReactDOM.render; + +render( + + + + + , + document.getElementById('interaction') +); diff --git a/packages/react-showroom/client/app/interaction-server-entry.tsx b/packages/react-showroom/client/app/interaction-server-entry.tsx new file mode 100644 index 00000000..59748d20 --- /dev/null +++ b/packages/react-showroom/client/app/interaction-server-entry.tsx @@ -0,0 +1,68 @@ +import { QueryClientProvider } from '@showroomjs/bundles/query'; +import { StaticRouter } from '@showroomjs/bundles/routing'; +import { flattenArray, NestedArray, Ssr } from '@showroomjs/core'; +import { ReactShowroomSection } from '@showroomjs/core/react'; +import { getCssText } from '@showroomjs/ui'; +import * as React from 'react'; +import * as ReactDOMServer from 'react-dom/server'; +import { Helmet } from 'react-helmet'; +import sections from 'react-showroom-sections'; +import { createQueryClient } from '../lib/create-query-client'; +import { factoryMap } from '../lib/lazy'; +import { InteractionApp } from './interaction-app'; + +export const ssr: Ssr = { + render: async ({ pathname }) => { + for (const [fn] of factoryMap) { + const result = await fn(); + factoryMap.set(fn, result.default); + } + + const queryClient = createQueryClient(); + + const result = ReactDOMServer.renderToString( + + + + + + ); + + return { + result, + cleanup: () => queryClient.clear(), + }; + }, + getCssText, + getHelmet: () => Helmet.renderStatic(), + getRoutes: async () => { + const result: NestedArray = []; + + async function getRoute(section: ReactShowroomSection): Promise { + if (section.type === 'group') { + await Promise.all(section.items.map(getRoute)); + } + + if (section.type === 'component') { + const { testMap } = await section.data.load(); + + if (testMap) { + result.push( + Object.values(testMap).map( + (testId) => `${section.metadata.id}/${testId}` + ) + ); + } + } + } + + for (const section of sections) { + await getRoute(section); + } + + return flattenArray(result); + }, +}; diff --git a/packages/react-showroom/client/app/preview-app.tsx b/packages/react-showroom/client/app/preview-app.tsx index 5be2720d..b6d6e12b 100644 --- a/packages/react-showroom/client/app/preview-app.tsx +++ b/packages/react-showroom/client/app/preview-app.tsx @@ -5,7 +5,7 @@ import { SupportedLanguage, } from '@showroomjs/core'; import { useMeasure } from '@showroomjs/measure'; -import { Alert, useConstant, useId } from '@showroomjs/ui'; +import { useConstant, useId } from '@showroomjs/ui'; import * as React from 'react'; import allImports from 'react-showroom-all-imports'; import CodeblockData from 'react-showroom-codeblocks'; @@ -13,6 +13,7 @@ import allCompMetadata from 'react-showroom-comp-metadata?showroomAllComp'; import Wrapper from 'react-showroom-wrapper'; import { AllComponents } from '../all-components'; import { CodePreviewFrame } from '../components/code-preview-frame'; +import { ErrorPage } from '../components/error-page'; import { CodeImportsContextProvider } from '../lib/code-imports-context'; import { CodeVariablesContextProvider } from '../lib/code-variables-context'; import { ComponentMetaContext } from '../lib/component-props-context'; @@ -495,14 +496,3 @@ const createCustomUseState = return [state, customSetState]; }; - -const ErrorPage = () => { - return ( -
- - Something goes wrong. This is probably a bug in{' '} - react-showroom. - -
- ); -}; diff --git a/packages/react-showroom/client/app/preview-server-entry.tsx b/packages/react-showroom/client/app/preview-server-entry.tsx index 4e507a45..093447ee 100644 --- a/packages/react-showroom/client/app/preview-server-entry.tsx +++ b/packages/react-showroom/client/app/preview-server-entry.tsx @@ -25,10 +25,7 @@ export const ssr: Ssr = { const queryClient = createQueryClient(); const result = ReactDOMServer.renderToString( - + diff --git a/packages/react-showroom/client/app/showroom-server-entry.tsx b/packages/react-showroom/client/app/showroom-server-entry.tsx index 07bdfd2a..f1325a15 100644 --- a/packages/react-showroom/client/app/showroom-server-entry.tsx +++ b/packages/react-showroom/client/app/showroom-server-entry.tsx @@ -21,7 +21,7 @@ export const ssr: Ssr = { const queryClient = createQueryClient(); const result = ReactDOMServer.renderToString( - + diff --git a/packages/react-showroom/client/components/code-preview-iframe.tsx b/packages/react-showroom/client/components/code-preview-iframe.tsx index 3c025e9f..1b6027f4 100644 --- a/packages/react-showroom/client/components/code-preview-iframe.tsx +++ b/packages/react-showroom/client/components/code-preview-iframe.tsx @@ -11,7 +11,6 @@ import { Enable as ResizeEnable, Resizable } from 're-resizable'; import * as React from 'react'; import { useComponentMeta } from '../lib/component-props-context'; import { DomEvent, Message, useParentWindow } from '../lib/frame-message'; -import { getFrameId } from '../lib/get-frame-id'; import { getPreviewUrl } from '../lib/preview-url'; import { getScrollFn } from '../lib/scroll-into-view'; import { useA11yResult } from '../lib/use-a11y-result'; @@ -165,15 +164,6 @@ export const CodePreviewIframe = styled(function CodePreviewIframe({ const resizableRef = React.useRef(null); - // React.useEffect(() => { - // if (resizableRef.current && initialWidth) { - // resizableRef.current.updateSize({ - // width: initialWidth, - // height, - // }); - // } - // }, []); - const content = codeHash ? ( { const { - content: { imports, codeblocks, Component, loadDts }, + content: { imports, codeblocks, Component, loadDts, testMap }, metadata, } = props; @@ -34,7 +35,17 @@ export const ComponentDataProvider = (props: { - {props.children} + ({ + componentId: metadata.id, + testMap, + }), + [metadata.id, testMap] + )} + > + {props.children} + diff --git a/packages/react-showroom/client/components/doc-placeholder.tsx b/packages/react-showroom/client/components/doc-placeholder.tsx index 82e09af1..5cc76c20 100644 --- a/packages/react-showroom/client/components/doc-placeholder.tsx +++ b/packages/react-showroom/client/components/doc-placeholder.tsx @@ -16,7 +16,7 @@ export const DocPlaceholder = styled(

There is no documentation/example for this component.

- {process.env.NODE_ENV === 'development' && ( + {process.env.REACT_SHOWROOM_COMMAND === 'server' && ( <>

Create a file at the following location to start adding diff --git a/packages/react-showroom/client/components/error-page.tsx b/packages/react-showroom/client/components/error-page.tsx new file mode 100644 index 00000000..1b1187d0 --- /dev/null +++ b/packages/react-showroom/client/components/error-page.tsx @@ -0,0 +1,13 @@ +import { Alert } from '@showroomjs/ui'; +import * as React from 'react'; + +export const ErrorPage = () => { + return ( +

+ + Something goes wrong. This is probably a bug in{' '} + react-showroom. + +
+ ); +}; diff --git a/packages/react-showroom/client/components/interaction-block.tsx b/packages/react-showroom/client/components/interaction-block.tsx new file mode 100644 index 00000000..de1fbea2 --- /dev/null +++ b/packages/react-showroom/client/components/interaction-block.tsx @@ -0,0 +1,51 @@ +import { styled } from '@showroomjs/ui'; +import * as React from 'react'; +import { useInteractions } from '../lib/interactions'; +import { PreviewConsoleProvider } from '../lib/use-preview-console'; +import { ConsolePanel } from './console-panel'; +import { InteractionIframe } from './interaction-iframe'; + +export interface InteractionBlockProps + extends React.ComponentPropsWithoutRef<'div'> { + testName: string; +} + +export const InteractionBlock = ({ + testName, + ...props +}: InteractionBlockProps) => { + const interaction = useInteractions(testName); + + if (!interaction || !interaction.testId) { + return null; + } + + return ( +
+ {testName} + + + + + + +
+ ); +}; + +const FrameWrapper = styled('div', { + width: '100%', + borderLeft: '1px solid $gray-200', + borderRight: '1px solid $gray-200', + borderBottom: '1px solid $gray-200', + roundedB: '$md', +}); + +const Title = styled('div', { + px: '$4', + py: '$3', + backgroundColor: '$gray-200', + roundedT: '$md', + fontSize: '$sm', + lineHeight: '$sm', +}); diff --git a/packages/react-showroom/client/components/interaction-iframe.tsx b/packages/react-showroom/client/components/interaction-iframe.tsx new file mode 100644 index 00000000..bb81d6ec --- /dev/null +++ b/packages/react-showroom/client/components/interaction-iframe.tsx @@ -0,0 +1,47 @@ +import { styled } from '@showroomjs/ui'; +import * as React from 'react'; +import { useParentWindow } from '../lib/frame-message'; +import { + getInteractionBlockUrl, + InteractionsContextType, +} from '../lib/interactions'; +import { useConsole } from '../lib/use-preview-console'; + +export const InteractionIframe = (props: { + data: InteractionsContextType & { testId: string }; +}) => { + const previewConsole = useConsole(); + const [frameHeight, setFrameHeight] = React.useState(100); + + const { targetRef } = useParentWindow((ev) => { + switch (ev.type) { + case 'log': + previewConsole[ev.level](...(ev.data || [])); + return; + + case 'heightChange': + setFrameHeight(ev.height); + return; + + default: + break; + } + }); + + return ( + + ); +}; + +const Frame = styled('iframe', { + width: '100%', + border: 0, + transition: 'height 300ms ease-in-out', +}); diff --git a/packages/react-showroom/client/components/sidebar.tsx b/packages/react-showroom/client/components/sidebar.tsx index 808fe81b..b57b9d93 100644 --- a/packages/react-showroom/client/components/sidebar.tsx +++ b/packages/react-showroom/client/components/sidebar.tsx @@ -5,6 +5,7 @@ import { DropdownMenu, IconButton, icons, + useIsClient, keyframes, Portal, styled, @@ -42,6 +43,8 @@ export const Sidebar = (props: { sections: Array }) => { return props.sections; }, [props.sections]); + const isClient = useIsClient(); + return ( <>
}) => { ))}
- + {isClient && } ); }; diff --git a/packages/react-showroom/client/index.tsx b/packages/react-showroom/client/index.tsx index a2ad7483..8ec72d0e 100644 --- a/packages/react-showroom/client/index.tsx +++ b/packages/react-showroom/client/index.tsx @@ -28,6 +28,7 @@ export { DeviceFrame } from './components/device-frame'; export { DocPlaceholder } from './components/doc-placeholder'; export type { DocPlaceholderProps } from './components/doc-placeholder'; export { ErrorBound } from './components/error-fallback'; +export { InteractionBlock } from './components/interaction-block'; export { MarkdownArticle } from './components/markdown-article'; export { MarkdownDataProvider } from './components/markdown-data-provider'; export { MarkdownDocStandaloneEditor } from './components/markdown-doc-standalone-editor'; diff --git a/packages/react-showroom/client/lib/frame-message.ts b/packages/react-showroom/client/lib/frame-message.ts index eb585c25..cec67166 100644 --- a/packages/react-showroom/client/lib/frame-message.ts +++ b/packages/react-showroom/client/lib/frame-message.ts @@ -42,20 +42,23 @@ export type DomEvent = | DomEventBase<'keyUp', KeyboardEventInit> | DomEventBase<'keyDown', KeyboardEventInit>; -export type Message = - | { - type: 'code'; - code: string; - lang: SupportedLanguage; - } +export type CommonMessage = + | LogMessage | { type: 'heightChange'; height: number; } | { type: 'ready'; + }; + +export type Message = + | CommonMessage + | { + type: 'code'; + code: string; + lang: SupportedLanguage; } - | LogMessage | { type: 'stateChange'; stateId: string; @@ -96,7 +99,7 @@ export type Message = color: string; }; -export const usePreviewWindow = (onMessage: (data: Message) => void) => { +const useChildFrame = (onMessage: (data: Message) => void) => { useMessage(onMessage, (ev) => ev.source === parent); useEffect(() => { @@ -133,6 +136,10 @@ export const usePreviewWindow = (onMessage: (data: Message) => void) => { }; }; +export const usePreviewWindow = useChildFrame; + +export const useInteractionWindow = useChildFrame; + export const useParentWindow = (onMessage?: (data: Message) => void) => { const targetRef = useRef(null); const messageQueue = useConstant>(() => []); diff --git a/packages/react-showroom/client/lib/interactions.tsx b/packages/react-showroom/client/lib/interactions.tsx new file mode 100644 index 00000000..0af55ec5 --- /dev/null +++ b/packages/react-showroom/client/lib/interactions.tsx @@ -0,0 +1,31 @@ +import { createNameContext } from '@showroomjs/ui'; +import * as React from 'react'; +import { basename } from './config'; + +export interface InteractionsContextType { + testMap?: Record; + componentId: string; +} + +export const InteractionsContext = createNameContext( + 'InteractionsContext', + { componentId: '' } +); + +export const useInteractions = (interactionName: string) => { + const context = React.useContext(InteractionsContext); + + const testId = context.testMap && context.testMap[interactionName]; + + return testId + ? { + ...context, + testId, + } + : undefined; +}; + +export const getInteractionBlockUrl = (data: { + componentId: string; + testId: string; +}) => `${basename}/_interaction/${data.componentId}/${data.testId}`; diff --git a/packages/react-showroom/client/lib/test-globals.ts b/packages/react-showroom/client/lib/test-globals.ts new file mode 100644 index 00000000..d963abeb --- /dev/null +++ b/packages/react-showroom/client/lib/test-globals.ts @@ -0,0 +1,30 @@ +const noop = () => {}; + +export const describe = noop; +export const test = noop; +export const it = noop; + +const expectMethods = [ + 'toBe', + 'toEqual', + 'toStrictEqual', + 'toBeVisible', + 'toMatchInlineSnapshot', + 'toBeCalled', + 'toBeDefined', +]; + +const expectObj = expectMethods.reduce( + (result, m) => ({ + ...result, + [m]: noop, + }), + {} +); + +export const expect = () => expectObj; + +export const jest = { + mock: noop, + spyOn: noop, +}; diff --git a/packages/react-showroom/client/route-mapping.tsx b/packages/react-showroom/client/route-mapping.tsx index c02a70f5..477867b3 100644 --- a/packages/react-showroom/client/route-mapping.tsx +++ b/packages/react-showroom/client/route-mapping.tsx @@ -141,6 +141,8 @@ export const loadCodeAtPath = ( fullPath: string, onLoad: () => void = noop ): void => { + console.log({ fullPath }); + const hashIndex = fullPath.indexOf('#'); const path = hashIndex > 0 ? fullPath.slice(0, hashIndex) : fullPath; diff --git a/packages/react-showroom/client/service-worker/_showroom-service-worker.ts b/packages/react-showroom/client/service-worker/_showroom-service-worker.ts index 53bd8233..90313f17 100644 --- a/packages/react-showroom/client/service-worker/_showroom-service-worker.ts +++ b/packages/react-showroom/client/service-worker/_showroom-service-worker.ts @@ -7,6 +7,9 @@ import { registerRoute, Route, setDefaultHandler } from 'workbox-routing'; import { CacheFirst, NetworkOnly } from 'workbox-strategies'; import { basename } from '../lib/config'; +// @ts-expect-error +self.__WB_DISABLE_DEV_LOGS = true; + const assets: Array<{ revision: string; url: string; diff --git a/packages/react-showroom/client/service-worker/service-worker-registration.ts b/packages/react-showroom/client/service-worker/service-worker-registration.ts index d6ceef91..20829d67 100644 --- a/packages/react-showroom/client/service-worker/service-worker-registration.ts +++ b/packages/react-showroom/client/service-worker/service-worker-registration.ts @@ -6,7 +6,10 @@ interface Config { } export function register(config?: Config) { - if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { + if ( + process.env.REACT_SHOWROOM_COMMAND === 'build' && + 'serviceWorker' in navigator + ) { window.addEventListener('load', () => { const swUrl = `${basename}/_showroom-service-worker.js`; diff --git a/packages/react-showroom/client/type-def.d.ts b/packages/react-showroom/client/type-def.d.ts index 353776c4..0c69b033 100644 --- a/packages/react-showroom/client/type-def.d.ts +++ b/packages/react-showroom/client/type-def.d.ts @@ -76,6 +76,15 @@ declare module 'react-showroom-index' { export default index; } +declare module 'react-showroom-tests' { + declare const testMap: Record< + string, + () => Promise Promise>> + >; + + export default testMap; +} + declare namespace NodeJS { import type { FrameDimension, Environment } from '@showroomjs/core'; import type { ThemeConfiguration } from '@showroomjs/core/react'; @@ -84,6 +93,7 @@ declare namespace NodeJS { interface ProcessEnv { readonly NODE_ENV: Environment; + readonly REACT_SHOWROOM_COMMAND: 'server' | 'build'; readonly REACT_SHOWROOM_THEME: ThemeConfiguration; readonly PRERENDER: boolean; readonly PRERENDER_EXAMPLE: boolean; diff --git a/packages/react-showroom/html-template/interaction.html b/packages/react-showroom/html-template/interaction.html new file mode 100644 index 00000000..d718cb44 --- /dev/null +++ b/packages/react-showroom/html-template/interaction.html @@ -0,0 +1,145 @@ + + + + + + + Interaction <% if (title) { %>- <%- title %><% } %> + + <% if (resetCss) { %> + + <% } %> + + <%= htmlWebpackPlugin.tags.headTags %> + + + + +
+ <%= htmlWebpackPlugin.tags.bodyTags %> + + diff --git a/packages/react-showroom/src/config/create-babel-preset.ts b/packages/react-showroom/src/config/create-babel-preset.ts index 84a214e3..a514ce6b 100644 --- a/packages/react-showroom/src/config/create-babel-preset.ts +++ b/packages/react-showroom/src/config/create-babel-preset.ts @@ -1,7 +1,6 @@ -import { Environment } from '@showroomjs/core'; import * as path from 'path'; -export const createBabelPreset = (env: Environment) => { +export const createBabelPreset = () => { const runTimeVersion = (function getBabelRuntimeVersion(): | string | undefined { @@ -10,8 +9,6 @@ export const createBabelPreset = (env: Environment) => { } catch (err) {} })(); - const isDev = env === 'development'; - let absoluteRuntimePath: string | undefined = undefined; try { @@ -37,7 +34,7 @@ export const createBabelPreset = (env: Environment) => { require.resolve('@babel/preset-react'), { runtime: 'automatic', - development: isDev, + development: true, }, ], require.resolve('@babel/preset-typescript'), diff --git a/packages/react-showroom/src/config/create-webpack-config.ts b/packages/react-showroom/src/config/create-webpack-config.ts index 7a40fa62..d7c728c7 100644 --- a/packages/react-showroom/src/config/create-webpack-config.ts +++ b/packages/react-showroom/src/config/create-webpack-config.ts @@ -16,6 +16,7 @@ import { createHash } from '../lib/create-hash'; import { generateAllComponents, generateAllComponentsPaths, + generateAllTests, generateCodeblocksData, generateDocPlaceHolder, generateSearchIndex, @@ -44,11 +45,11 @@ const WebpackMessages = require('webpack-messages'); const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); export const createClientWebpackConfig = ( - mode: Environment, + command: 'server' | 'build', config: NormalizedReactShowroomConfiguration, { outDir = 'showroom', profileWebpack = false } = {} ): webpack.Configuration => { - const baseConfig = createBaseWebpackConfig(mode, config, { + const baseConfig = createBaseWebpackConfig(command, config, { ssr: false, profile: profileWebpack, }); @@ -62,7 +63,7 @@ export const createClientWebpackConfig = ( html, } = config; - const isProd = mode === 'production'; + const isBuild = command === 'build'; const clientEntry = resolveShowroom( 'client-dist/app/showroom-client-entry.js' @@ -70,6 +71,11 @@ export const createClientWebpackConfig = ( const previewEntry = resolveShowroom( 'client-dist/app/preview-client-entry.js' ); + const interactionEntry = resolveShowroom( + 'client-dist/app/interaction-client-entry.js' + ); + + const enableInteraction = config.experiments.interactions; return mergeWebpackConfig( merge(baseConfig, { @@ -80,11 +86,18 @@ export const createClientWebpackConfig = ( preview: config.require ? config.require.concat(previewEntry) : previewEntry, + ...(enableInteraction + ? { + interaction: config.require + ? config.require.concat(interactionEntry) + : interactionEntry, + } + : {}), }, externals: ['crypto'], output: { path: resolveApp(outDir), - publicPath: !isProd ? '/' : `${basePath}/`, // need to add trailing slash + publicPath: !isBuild ? '/' : `${basePath}/`, // need to add trailing slash }, plugins: [ // workaround as html-webpack-plugin not compatible with ProfilingPlugin. See https://github.com/jantimon/html-webpack-plugin/issues/1652. @@ -97,10 +110,10 @@ export const createClientWebpackConfig = ( favicon: theme.favicon, resetCss: theme.resetCss, backgroundColor: theme.colors['primary-800'], - linkManifest: theme.manifest && isProd, + linkManifest: theme.manifest && isBuild, basePath, }, - minify: isProd && { + minify: isBuild && { collapseWhitespace: true, keepClosingSlash: true, removeComments: true, @@ -125,9 +138,9 @@ export const createClientWebpackConfig = ( templateParameters: { title: theme.title, resetCss: theme.resetCss, - prerender: mode === 'production' && !!prerenderConfig, + prerender: isBuild && !!prerenderConfig, }, - minify: isProd && { + minify: isBuild && { collapseWhitespace: true, keepClosingSlash: true, removeComments: true, @@ -146,12 +159,35 @@ export const createClientWebpackConfig = ( files: ['**/_preview.html'], }) : undefined, + enableInteraction + ? new HtmlWebpackPlugin({ + filename: '_interaction.html', + template: resolveShowroom('html-template/interaction.html'), + templateParameters: { + title: theme.title, + resetCss: theme.resetCss, + prerender: isBuild && !!prerenderConfig, + }, + minify: isBuild && { + collapseWhitespace: true, + keepClosingSlash: true, + removeComments: true, + ignoreCustomComments: [/SSR-/], + removeRedundantAttributes: true, + removeScriptTypeAttributes: true, + removeStyleLinkTypeAttributes: true, + useShortDoctype: true, + }, + inject: false, + chunks: ['interaction'], + }) + : undefined, ]), new WebpackMessages({ name: 'showroom', logger: logToStdout, }), - isProd && assetDir + isBuild && assetDir ? new CopyWebpackPlugin({ patterns: [ { @@ -165,18 +201,19 @@ export const createClientWebpackConfig = ( ], }) : undefined, - theme.serviceWorker && isProd + theme.serviceWorker && isBuild ? new (require('workbox-webpack-plugin').InjectManifest)({ swSrc: resolveShowroom( 'client/service-worker/_showroom-service-worker.ts' ), - exclude: [/.wasm$/, /.map$/, /.html$/], + exclude: [/.wasm$/, /.map$/, /.html$/, /.js$/], + mode: 'production', }) : undefined, ].filter(isDefined), }), userConfig, - mode + 'development' ); }; @@ -185,7 +222,7 @@ export const createSsrWebpackConfig = ( config: NormalizedReactShowroomConfiguration, { outDir = 'showroom', profileWebpack = false } = {} ): webpack.Configuration => { - const baseConfig = createBaseWebpackConfig(mode, config, { + const baseConfig = createBaseWebpackConfig('build', config, { ssr: true, profile: profileWebpack, }); @@ -203,6 +240,13 @@ export const createSsrWebpackConfig = ( ...(config.require ? { requireConfig: config.require } : {}), prerender: showroomServer, previewPrerender: previewServer, + ...(config.experiments.interactions + ? { + interactionPrerender: resolveShowroom( + 'client-dist/app/interaction-server-entry.js' + ), + } + : {}), }, output: { path: resolveApp(`${outDir}/server`), @@ -226,7 +270,7 @@ export const createSsrWebpackConfig = ( }; const createBaseWebpackConfig = ( - mode: Environment, + command: 'build' | 'server', config: NormalizedReactShowroomConfiguration, options: { ssr: boolean; profile: boolean } ): webpack.Configuration => { @@ -248,8 +292,8 @@ const createBaseWebpackConfig = ( compilerOptions, } = config; - const isProd = mode === 'production'; - const isDev = mode === 'development'; + const isBuild = command === 'build'; + const isServer = command === 'server'; const docgenParser = docgen.withCustomConfig( docgenConfig.tsconfigPath, @@ -288,9 +332,11 @@ const createBaseWebpackConfig = ( generateDocPlaceHolder(exampleConfig.placeholder), [resolveShowroom('node_modules/react-showroom-index.js')]: generateSearchIndex(sections, search.includeHeadings), + [resolveShowroom('node_modules/react-showroom-tests.js')]: + generateAllTests(sections), }); - const babelPreset = createBabelPreset(mode); + const babelPreset = createBabelPreset(); const codeBlocksOptions: ShowroomRemarkCodeBlocksLoaderOptions = { filter: (code) => !isString(code.meta) || !code.meta.includes('static'), @@ -301,17 +347,17 @@ const createBaseWebpackConfig = ( }; return { - mode, + mode: 'development', resolve: { extensions: moduleFileExtensions.map((ext) => `.${ext}`), }, output: { - filename: isProd ? '_assets/js/[name].[contenthash:8].js' : '[name].js', - chunkFilename: isProd + filename: isBuild ? '_assets/js/[name].[contenthash:8].js' : '[name].js', + chunkFilename: isBuild ? '_assets/js/[name].[contenthash:8].js' : '[name].js', assetModuleFilename: '_assets/media/[name]-[contenthash][ext][query]', - clean: isProd, + clean: isBuild, }, module: { rules: [ @@ -371,7 +417,7 @@ const createBaseWebpackConfig = ( loader: require.resolve('babel-loader'), options: { presets: [() => babelPreset], - plugins: isDev + plugins: isServer ? [require.resolve('react-refresh/babel')] : undefined, babelrc: false, @@ -382,18 +428,38 @@ const createBaseWebpackConfig = ( }, { test: /\.(ts|tsx)$/, - resourceQuery: { - not: [/raw/], - }, - use: [ + oneOf: [ { - loader: require.resolve('babel-loader'), - options: { - presets: [() => babelPreset], - plugins: isDev - ? [require.resolve('react-refresh/babel')] - : undefined, + resourceQuery: /showroomTestName/, + use: [ + { + loader: 'showroom-test-name-loader', + }, + ], + }, + { + resourceQuery: /showroomTest/, + use: [ + { + loader: 'showroom-test-loader', + }, + ], + }, + { + resourceQuery: { + not: [/raw/], }, + use: [ + { + loader: require.resolve('babel-loader'), + options: { + presets: [() => babelPreset], + plugins: isServer + ? [require.resolve('react-refresh/babel')] + : undefined, + }, + }, + ], }, ], }, @@ -509,7 +575,7 @@ const createBaseWebpackConfig = ( loader: require.resolve('babel-loader'), options: { presets: [() => babelPreset], - plugins: isProd + plugins: isBuild ? undefined : [require.resolve('react-refresh/babel')], babelrc: false, @@ -555,7 +621,7 @@ const createBaseWebpackConfig = ( }), sideEffects: true, use: [ - isProd + isBuild ? { loader: MiniCssExtractPlugin.loader, options: { @@ -569,7 +635,7 @@ const createBaseWebpackConfig = ( importLoaders: css.usePostcss ? 1 : 0, modules: { auto: true, - localIdentName: isProd + localIdentName: isBuild ? '[hash:base64]' : '[path][name]__[local]', }, @@ -579,7 +645,7 @@ const createBaseWebpackConfig = ( ? { loader: require.resolve('postcss-loader'), options: { - sourceMap: isProd, + sourceMap: isBuild, postcssOptions: { config: paths.appPostcssConfig, }, @@ -593,11 +659,11 @@ const createBaseWebpackConfig = ( resolveLoader: { modules: ['node_modules', path.resolve(__dirname, '../webpack-loader')], }, - devtool: isProd ? 'source-map' : 'cheap-module-source-map', + devtool: isBuild ? 'source-map' : 'cheap-module-source-map', cache: cacheDir ? { type: 'filesystem', - name: `react-showroom-${mode}-${options.ssr ? 'ssr' : 'client'}${ + name: `react-showroom-${command}-${options.ssr ? 'ssr' : 'client'}${ prerenderConfig ? '-prerender' : '' }`, version: [ @@ -620,19 +686,20 @@ const createBaseWebpackConfig = ( } : undefined, plugins: [ - isProd + isBuild ? new MiniCssExtractPlugin({ filename: '_assets/css/[name].[contenthash].css', chunkFilename: '_assets/css/[name].[contenthash].css', }) : undefined, new webpack.EnvironmentPlugin({ - PRERENDER: isProd, + PRERENDER: isBuild, SSR: options.ssr, - BASE_PATH: isProd ? basePath : '', + BASE_PATH: isBuild ? basePath : '', PRERENDER_EXAMPLE: !!prerenderConfig, REACT_SHOWROOM_THEME: theme, - NODE_ENV: mode, + NODE_ENV: 'development', + REACT_SHOWROOM_COMMAND: command, EXAMPLE_DIMENSIONS: exampleConfig.dimensions, ENABLE_ADVANCED_EDITOR: exampleConfig.enableAdvancedEditor, SYNC_STATE_TYPE: exampleConfig.syncStateType, @@ -645,7 +712,7 @@ const createBaseWebpackConfig = ( USE_SW: theme.serviceWorker, }), virtualModules, - isDev + isServer ? new ReactRefreshWebpackPlugin({ overlay: false, }) @@ -657,16 +724,16 @@ const createBaseWebpackConfig = ( ), }) : undefined, - isProd + isBuild ? new webpack.optimize.MinChunkSizePlugin({ minChunkSize: 1000, }) : undefined, ].filter(isDefined), optimization: { - minimize: !options.ssr && isProd, + minimize: !options.ssr && isBuild, minimizer: [ - '...', // keep existing minimizer + // '...', // keep existing minimizer new CssMinimizerPlugin(), ], splitChunks: { @@ -677,7 +744,7 @@ const createBaseWebpackConfig = ( hints: false, }, infrastructureLogging: { - level: debug ? 'info' : isProd ? 'info' : 'none', + level: debug ? 'info' : isBuild ? 'info' : 'none', }, stats: debug ? 'normal' : 'none', experiments: { diff --git a/packages/react-showroom/src/lib/esbuild-util.ts b/packages/react-showroom/src/lib/esbuild-util.ts new file mode 100644 index 00000000..5b152f3b --- /dev/null +++ b/packages/react-showroom/src/lib/esbuild-util.ts @@ -0,0 +1,5 @@ +export const isSupportedExt = ( + ext: string +): ext is 'js' | 'ts' | 'jsx' | 'tsx' => { + return ['js', 'ts', 'jsx', 'tsx'].includes(ext); +}; diff --git a/packages/react-showroom/src/lib/generate-showroom-data.ts b/packages/react-showroom/src/lib/generate-showroom-data.ts index 3e6aadc2..9476ff55 100644 --- a/packages/react-showroom/src/lib/generate-showroom-data.ts +++ b/packages/react-showroom/src/lib/generate-showroom-data.ts @@ -71,7 +71,7 @@ function compileComponentSection( enableAdvancedEditor: boolean; } ): string { - const { docPath, sourcePath } = component; + const { docPath, sourcePath, testPath } = component; const { name: componentName } = path.parse(sourcePath); @@ -80,6 +80,11 @@ function compileComponentSection( const loadDoc = import(/* webpackChunkName: "${componentName}-doc" */'${docPath}'); const loadImports = import(/* webpackChunkName: "${componentName}-imports" */'${docPath}?showroomRemarkImports'); const loadCodeBlocks = import(/* webpackChunkName: "${componentName}-codeblocks" */'${docPath}?showroomRemarkCodeblocks'); + ${ + testPath + ? `const loadTestMap = import(/* webpackChunkName: "${componentName}-test-map" */'${testPath}?showroomTestName')` + : '' + } const Component = await import(/* webpackChunkName: "${componentName}" */'${sourcePath}'); const { default: doc, headings } = await loadDoc; @@ -96,6 +101,7 @@ function compileComponentSection( ? `import('${docPath}?showroomRemarkImportsDts')` : `Promise.resolve({default: {}})` }, + ${testPath ? `testMap: (await loadTestMap).default,` : ''} } }` : `async () => { @@ -378,6 +384,38 @@ export const generateAllComponentsPaths = ( return JSON.stringify(result, null, 2); }; +export const generateAllTests = ( + sections: Array +) => { + const compTestData: Array<{ + id: string; + loadFn: string; + }> = []; + + (function collect(items) { + items.forEach((item) => { + if (item.type === 'group') { + collect(item.items); + return; + } + + if (item.type === 'component') { + if (item.testPath) { + compTestData.push({ + id: item.id, + loadFn: `() => import('${item.testPath}?showroomTest')`, + }); + } + return; + } + }); + })(sections); + + return `export default {${compTestData + .map((data) => `${data.id}: ${data.loadFn},`) + .join('\n')}}`; +}; + export const generateWrapper = (wrapper: string | undefined) => { if (wrapper) { return `import Wrapper from '${wrapper}'; diff --git a/packages/react-showroom/src/lib/get-config.ts b/packages/react-showroom/src/lib/get-config.ts index 463d59d1..e8e8d5b5 100644 --- a/packages/react-showroom/src/lib/get-config.ts +++ b/packages/react-showroom/src/lib/get-config.ts @@ -1,7 +1,6 @@ import { deviceDimensionsByName, DeviceName, - Environment, flattenArray, FrameDimension, isString, @@ -18,6 +17,7 @@ import { } from '@showroomjs/core/react'; import * as fs from 'fs'; import * as glob from 'glob'; +import * as mimeTypes from 'mime-types'; import { yellow } from 'nanocolors'; import * as path from 'path'; import nightOwlTheme from 'prism-react-renderer/themes/nightOwl'; @@ -28,7 +28,6 @@ import type { defineConfig } from '../index'; import { createHash } from './create-hash'; import { logToStdout } from './log-to-stdout'; import { paths, resolveApp } from './paths'; -import * as mimeTypes from 'mime-types'; const DEFAULT_COMPONENTS_GLOB = 'src/components/**/*.tsx'; const DEFAULT_IGNORES = [ @@ -88,7 +87,7 @@ const deviceDevices: Array = [ let _normalizedConfig: NormalizedReactShowroomConfiguration; export const getConfig = ( - env: Environment, + command: 'build' | 'server', configFile?: string, userConfig?: ReactShowroomConfiguration ): NormalizedReactShowroomConfiguration => { @@ -135,10 +134,11 @@ export const getConfig = ( } = {}, html = {}, search: { - includeHeadings: searchIncludeHeadings = env === 'production', + includeHeadings: searchIncludeHeadings = command === 'build', } = {}, + experiments: { interactions: interactionsExperiment = false } = {}, ...providedConfig - } = userConfig || getUserConfig(env, configFile); + } = userConfig || getUserConfig(command, configFile); const sections: Array = []; const components: Array = []; @@ -150,7 +150,10 @@ export const getConfig = ( ignore: ignores, }); - collectComponents(componentPaths, sections, [], false); + collectComponents(componentPaths, sections, [], { + hideFromSidebar: false, + findTest: interactionsExperiment, + }); } else if (!items) { const componentPaths = glob.sync(DEFAULT_COMPONENTS_GLOB, { cwd: paths.appPath, @@ -158,11 +161,14 @@ export const getConfig = ( ignore: ignores, }); - collectComponents(componentPaths, sections, [], false); + collectComponents(componentPaths, sections, [], { + hideFromSidebar: false, + findTest: interactionsExperiment, + }); } if (items) { - collectSections(items, sections, []); + collectSections(items, sections, [], { findTest: interactionsExperiment }); } if (!sections.some((section) => 'slug' in section && section.slug === '')) { @@ -272,6 +278,9 @@ export const getConfig = ( includeHeadings: searchIncludeHeadings, }, compilerOptions: (config && config.compilerOptions) || {}, + experiments: { + interactions: interactionsExperiment, + }, }; return _normalizedConfig; @@ -280,29 +289,24 @@ export const getConfig = ( componentPaths: Array, parent: Array, parentSlugs: Array, - hideFromSidebar: boolean | undefined + options: { + hideFromSidebar: boolean | undefined; + findTest: boolean; + } ) { componentPaths.forEach((comPath) => { const comPathInfo = path.parse(comPath); - let docPath: string | null = null; - - for (const ext of COMPONENT_DOC_EXTENSIONS) { - const possibleDocPath = `${comPathInfo.dir}/${comPathInfo.name}${ext}`; - - if (fs.existsSync(possibleDocPath)) { - docPath = possibleDocPath; - break; - } - } - const section: ReactShowroomComponentSectionConfig = { type: 'component', sourcePath: comPath, - docPath, + docPath: getAlternativeFile(comPathInfo, COMPONENT_DOC_EXTENSIONS), + testPath: options.findTest + ? getAlternativeFile(comPathInfo, TEST_FILE_EXTENSIONS) + : null, parentSlugs, id: createHash(comPath), - hideFromSidebar, + hideFromSidebar: options.hideFromSidebar, }; components.push(section); @@ -313,7 +317,10 @@ export const getConfig = ( function collectSections( sectionConfigs: Array, parent: Array, - parentSlugs: Array + parentSlugs: Array, + options: { + findTest: boolean; + } ) { sectionConfigs.forEach((sectionConfig) => { switch (sectionConfig.type) { @@ -327,7 +334,8 @@ export const getConfig = ( parent, sectionConfig.path ? parentSlugs.concat(sectionConfig.path) - : parentSlugs + : parentSlugs, + options ); return; } @@ -354,7 +362,8 @@ export const getConfig = ( collectSections( sectionConfig.items, section.items, - parentSlugs.concat(slug) + parentSlugs.concat(slug), + options ); parent.push(section); @@ -391,7 +400,10 @@ export const getConfig = ( sectionConfig.path ? parentSlugs.concat(sectionConfig.path) : parentSlugs, - sectionConfig.hideFromSidebar + { + hideFromSidebar: sectionConfig.hideFromSidebar, + findTest: options.findTest, + } ); return; } @@ -419,7 +431,10 @@ export const getConfig = ( componentPaths, section.items, parentSlugs.concat(slug), - sectionConfig.hideFromSidebar + { + hideFromSidebar: sectionConfig.hideFromSidebar, + findTest: options.findTest, + } ); parent.push(section); @@ -557,7 +572,7 @@ function normalizeDimensions( } const getUserConfig = ( - env: Environment, + command: 'server' | 'build', configFile?: string ): ReactShowroomConfiguration => { const configFilePath = configFile @@ -571,8 +586,38 @@ const getUserConfig = ( const provided: ReturnType = require(configFilePath); return typeof provided === 'function' - ? provided(env === 'development' ? 'dev' : 'build') + ? provided(command === 'server' ? 'dev' : 'build') : provided; }; const COMPONENT_DOC_EXTENSIONS = ['.mdx', '.md'] as const; + +const TEST_ENDING = ['.spec', '.test'] as const; + +const TEST_FILE_ENDING = ['.tsx', '.ts', '.jsx', '.js'] as const; + +const TEST_FILE_EXTENSIONS = flattenArray( + TEST_ENDING.map((end) => + TEST_FILE_ENDING.map( + ( + ext + ): `${typeof TEST_ENDING[number]}${typeof TEST_FILE_ENDING[number]}` => + `${end}${ext}` + ) + ) +); + +const getAlternativeFile = ( + pathInfo: path.ParsedPath, + extensions: ReadonlyArray +): string | null => { + for (const ext of extensions) { + const possibleDocPath = `${pathInfo.dir}/${pathInfo.name}${ext}`; + + if (fs.existsSync(possibleDocPath)) { + return possibleDocPath; + } + } + + return null; +}; diff --git a/packages/react-showroom/src/lib/test-globals.ts b/packages/react-showroom/src/lib/test-globals.ts new file mode 100644 index 00000000..6ff25304 --- /dev/null +++ b/packages/react-showroom/src/lib/test-globals.ts @@ -0,0 +1,10 @@ +const noop = () => {}; + +export const describe = noop; +export const test = noop; +export const it = noop; + +export const jest = { + mock: noop, + spyOn: noop, +}; diff --git a/packages/react-showroom/src/node-api/build-showroom.ts b/packages/react-showroom/src/node-api/build-showroom.ts index 8f6d809b..74bcd3c4 100644 --- a/packages/react-showroom/src/node-api/build-showroom.ts +++ b/packages/react-showroom/src/node-api/build-showroom.ts @@ -1,9 +1,6 @@ require('source-map-support').install(); -// Do this as the first thing so that any code reading it knows the right env. -process.env.BABEL_ENV = 'production'; -process.env.NODE_ENV = 'production'; -import { Ssr, omit } from '@showroomjs/core'; +import { isDefined, omit, Ssr } from '@showroomjs/core'; import { NormalizedReactShowroomConfiguration, ReactShowroomConfiguration, @@ -16,14 +13,14 @@ import { createClientWebpackConfig } from '../config/create-webpack-config'; import { createSSrBundle } from '../lib/create-ssr-bundle'; import { generateDts } from '../lib/generate-dts'; import { getConfig } from '../lib/get-config'; -import { green, logToStdout } from '../lib/log-to-stdout'; +import { green, logToStdout, yellow } from '../lib/log-to-stdout'; import { resolveApp, resolveShowroom } from '../lib/paths'; async function buildStaticSite( config: NormalizedReactShowroomConfiguration, profile = false ) { - const webpackConfig = createClientWebpackConfig('production', config, { + const webpackConfig = createClientWebpackConfig('build', config, { outDir: config.outDir, profileWebpack: profile, }); @@ -178,17 +175,76 @@ async function prerenderPreview( return pageCount; } +async function prerenderInteraction( + config: NormalizedReactShowroomConfiguration, + tmpDir: string +) { + const prerenderCodePath = `${tmpDir}/server/interactionPrerender.js`; + const htmlPath = resolveApp(`${config.outDir}/_interaction.html`); + + const { ssr } = require(prerenderCodePath) as { ssr: Ssr }; + + const template = await fs.readFile(htmlPath, 'utf-8'); + + const routes = await ssr.getRoutes(); + + let pageCount = 0; + + for (const route of routes) { + if (route !== '') { + pageCount++; + + await fs.outputFile( + resolveApp(`${config.outDir}/_interaction/${route}/index.html`), + await getHtml(`/${route}`) + ); + } + } + + async function getHtml(pathname: string) { + const prerenderResult = await ssr.render({ pathname }); + const helmet = ssr.getHelmet(); + const finalHtml = template + .replace( + '', + `` + ) + .replace( + '', + `${helmet.title.toString()}${helmet.meta.toString()}${helmet.link.toString()}` + ) + .replace('', prerenderResult.result); + + prerenderResult.cleanup(); + + return finalHtml; + } + + return pageCount; +} + export async function buildShowroom( userConfig?: ReactShowroomConfiguration, configFile?: string, profile?: boolean ) { - const config = getConfig('production', configFile, userConfig); + const config = getConfig('build', configFile, userConfig); if (config.example.enableAdvancedEditor) { await generateDts(config, false); } + const enableInteractions = config.experiments.interactions; + + if (enableInteractions) { + logToStdout(yellow(`You are enabling experimental interactions features.`)); + logToStdout( + yellow( + `APIs for experimental features are not finalized and may be changed in minor version.` + ) + ); + } + const ssrDir = resolveShowroom( `ssr-result-${Date.now() + performance.now()}` ); @@ -203,12 +259,22 @@ export async function buildShowroom( createSSrBundle(config, ssrDir, profile), ]); logToStdout('Prerendering...'); - const [sitePageCount, previewPageCount] = await Promise.all([ - prerenderSite(config, ssrDir), - prerenderPreview(config, ssrDir), - ]); + const [sitePageCount, previewPageCount, interactionPageCount] = + await Promise.all([ + prerenderSite(config, ssrDir), + prerenderPreview(config, ssrDir), + enableInteractions ? prerenderInteraction(config, ssrDir) : undefined, + ]); logToStdout( - green(`Prerendered ${sitePageCount + previewPageCount} pages.`) + green( + `Prerendered ${ + sitePageCount + previewPageCount + (interactionPageCount || 0) + } pages (Site: ${sitePageCount}, Preview: ${previewPageCount}${ + isDefined(interactionPageCount) + ? `, Interaction: ${interactionPageCount}` + : '' + }).` + ) ); logToStdout(`Generated showroom at`); logToStdout(` - ${green(resolveApp(config.outDir))}`); diff --git a/packages/react-showroom/src/node-api/start-dev-server.ts b/packages/react-showroom/src/node-api/start-dev-server.ts index e3b64b2a..5b01e3fd 100644 --- a/packages/react-showroom/src/node-api/start-dev-server.ts +++ b/packages/react-showroom/src/node-api/start-dev-server.ts @@ -33,7 +33,7 @@ export async function startDevServer( ) { logToStdout('Starting dev server...'); - const config = getConfig('development', configFile, userConfig); + const config = getConfig('server', configFile, userConfig); const { devServerPort, assetDir, example } = config; @@ -44,7 +44,7 @@ export async function startDevServer( const HOST = '0.0.0.0'; const PORT = Number((argv as any).port ?? process.env.PORT ?? devServerPort); - const webpackConfig = createClientWebpackConfig('development', config); + const webpackConfig = createClientWebpackConfig('server', config); const devServerOptions = Object.assign( { port: PORT, @@ -56,6 +56,7 @@ export async function startDevServer( historyApiFallback: { rewrites: [ { from: /^\/_preview/, to: '/_preview.html' }, + { from: /^\/_interaction/, to: '/_interaction.html' }, { from: /./, to: '/index.html' }, ], }, diff --git a/packages/react-showroom/src/webpack-loader/showroom-test-loader.ts b/packages/react-showroom/src/webpack-loader/showroom-test-loader.ts new file mode 100644 index 00000000..ddc630c6 --- /dev/null +++ b/packages/react-showroom/src/webpack-loader/showroom-test-loader.ts @@ -0,0 +1,37 @@ +import { compileTests } from '@showroomjs/core'; +import * as esbuild from 'esbuild'; +import type { LoaderDefinition } from 'webpack'; +import { isSupportedExt } from '../lib/esbuild-util'; + +const showroomTestLoader: LoaderDefinition = function (source) { + const callback = this.async(); + const filePath = this.resourcePath; + + const match = filePath.match(/\.(\w+)$/); + + if (!match) { + return callback(new Error(`${filePath} is not having supported ext.`)); + } + + const ext = match[1]; + + if (!isSupportedExt(ext)) { + return callback(new Error(`${filePath} is not having supported ext.`)); + } + + (async function compile() { + try { + const transformResult = await esbuild.transform(source, { + loader: ext, + }); + + const testFileContent = compileTests(transformResult.code); + + callback(null, testFileContent); + } catch (err) { + callback(err as Error); + } + })(); +}; + +module.exports = showroomTestLoader; diff --git a/packages/react-showroom/src/webpack-loader/showroom-test-name-loader.ts b/packages/react-showroom/src/webpack-loader/showroom-test-name-loader.ts new file mode 100644 index 00000000..3fae423f --- /dev/null +++ b/packages/react-showroom/src/webpack-loader/showroom-test-name-loader.ts @@ -0,0 +1,40 @@ +import { compileTestsToMap } from '@showroomjs/core'; +import * as esbuild from 'esbuild'; +import type { LoaderDefinition } from 'webpack'; +import { isSupportedExt } from '../lib/esbuild-util'; + +const showroomTestNameLoader: LoaderDefinition = function (source) { + const callback = this.async(); + + const match = this.resourcePath.match(/\.(\w+)$/); + + if (!match) { + return callback( + new Error(`${this.resourcePath} is not having supported ext.`) + ); + } + + const ext = match[1]; + + if (!isSupportedExt(ext)) { + return callback( + new Error(`${this.resourcePath} is not having supported ext.`) + ); + } + + (async function compile() { + try { + const transformResult = await esbuild.transform(source, { + loader: ext, + }); + + const result = compileTestsToMap(transformResult.code); + + return callback(null, result); + } catch (err) { + callback(err as Error); + } + })(); +}; + +module.exports = showroomTestNameLoader; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 94f05672..95b88baa 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -34,6 +34,8 @@ export { TextTooltip, Tooltip } from './components/tooltip'; export * from './lib'; export { copyText } from './lib/copy'; export { createNameContext } from './lib/create-named-context'; +export { listenForConsole } from './lib/listen-for-console'; +export type { ConsoleEvent } from './lib/listen-for-console'; export { useDebounce } from './lib/use-debounce'; export { useDebouncedCallback } from './lib/use-debounced-callback'; export { IsClientContextProvider, useIsClient } from './lib/use-is-client'; diff --git a/packages/ui/src/lib/listen-for-console.ts b/packages/ui/src/lib/listen-for-console.ts new file mode 100644 index 00000000..97999bc5 --- /dev/null +++ b/packages/ui/src/lib/listen-for-console.ts @@ -0,0 +1,42 @@ +import mitt from 'mitt'; + +const cMethods = ['log', 'error', 'warn', 'info'] as const; + +export type ConsoleEvent = { + invoke: { + method: typeof cMethods[number]; + data: Array; + }; +}; + +export const listenForConsole = (consoleInstance = console) => { + const events = mitt(); + const oriMethodMap = new Map< + typeof cMethods[number], + (...data: any) => void + >(); + + cMethods.forEach((method) => { + const oriMethod = consoleInstance[method]; + oriMethodMap.set(method, oriMethod); + + consoleInstance[method] = function (...data: any[]) { + events.emit('invoke', { + method, + data, + }); + + oriMethod.apply(consoleInstance, data); + }; + }); + + return { + events, + cleanup() { + oriMethodMap.forEach((oriMethod, key) => { + consoleInstance[key] = oriMethod; + }); + events.all.clear(); + }, + }; +};