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();
+ },
+ };
+};