import type {BuildOptions} from 'esbuild' import esbuild from 'esbuild' import {readdir, readFile, stat, writeFile} from 'fs/promises' import {mapValues} from 'lodash-es' import path from 'path' import React from 'react' import {renderToStaticMarkup} from 'react-dom/server' import {ServerStyleSheet} from 'styled-components' import {definedGlobals} from '../../../theatre/devEnv/definedGlobals' import {createEsbuildLiveReloadTools} from './createEsbuildLiveReloadTools' import {createProxyServer} from './createProxyServer' import {PlaygroundPage} from './home/PlaygroundPage' import {openForOS} from './openForOS' import {tryMultiplePorts} from './tryMultiplePorts' const playgroundDir = (folder: string) => path.join(__dirname, '..', folder) const buildDir = playgroundDir('build') const srcDir = playgroundDir('src') const sharedDir = playgroundDir('src/shared') const personalDir = playgroundDir('src/personal') const testDir = playgroundDir('src/tests') async function start(options: { /** enable live reload and watching stuff */ dev: boolean /** make some UI elements predictable by setting the __IS_VISUAL_REGRESSION_TESTING value on window */ isVisualRegressionTesting: boolean serve?: { findAvailablePort: boolean openBrowser: boolean /** defaults to 8080 */ defaultPort?: number } }): Promise { const defaultPort = options.serve?.defaultPort ?? 8080 const liveReload = options.serve && options.dev ? createEsbuildLiveReloadTools() : undefined type PlaygroundExample = { useHtml?: string entryFilePath: string outDir: string } type Groups = { [group: string]: { [module: string]: PlaygroundExample } } // Collect all entry directories per module per group const groups: Groups = await Promise.all( [sharedDir, personalDir, testDir].map(async (groupDir) => { let groupDirItems: string[] try { groupDirItems = await readdir(groupDir) } catch (error) { // If the group dir doesn't exist, we just set its entry to undefined return [path.basename(groupDir), undefined] } const allEntries = await Promise.all( groupDirItems.map( async ( moduleDirName, ): Promise<[string, PlaygroundExample | undefined]> => { const playgroundKey = path.basename(moduleDirName) const entryFilePath = path.join( groupDir, moduleDirName, 'index.tsx', ) if ( !(await stat(entryFilePath) .then((s) => s.isFile()) .catch(() => false)) ) return [playgroundKey, undefined] const playgroundExample = { useHtml: await readFile( path.join(groupDir, moduleDirName, 'index.html'), 'utf-8', ).catch(() => undefined), entryFilePath, outDir: path.join( buildDir, path.basename(groupDir), moduleDirName, ), } return [playgroundKey, playgroundExample] }, ), ) const validEntries = allEntries.filter( ([_, playgroundExample]) => playgroundExample !== undefined, ) return [path.basename(groupDir), Object.fromEntries(validEntries)] }), ).then((entries) => Object.fromEntries( // and then filter it out. entries.filter((entry) => entry[1] !== undefined), ), ) // Collect all entry files const entryPoints = Object.values(groups) .flatMap((group) => Object.values(group)) .map((module) => module.entryFilePath) // Collect all output directories const outModules: PlaygroundExample[] = Object.values(groups).flatMap( (group) => Object.values(group), ) // Render home page contents const homeHtml = (() => { const sheet = new ServerStyleSheet() try { const html = renderToStaticMarkup( sheet.collectStyles( React.createElement(PlaygroundPage, { groups: mapValues(groups, (group) => Object.keys(group)), }), ), ) const styleTags = sheet.getStyleTags() // or sheet.getStyleElement(); sheet.seal() return { head: styleTags, html, } } catch (error) { // handle error console.error(error) sheet.seal() process.exit(1) } })() const esbuildConfig: BuildOptions = { entryPoints, bundle: true, sourcemap: true, outdir: buildDir, target: ['firefox88'], loader: { '.png': 'file', '.glb': 'file', '.gltf': 'file', '.mp3': 'file', '.ogg': 'file', '.svg': 'dataurl', }, define: { ...definedGlobals, 'window.__IS_VISUAL_REGRESSION_TESTING': JSON.stringify( options.isVisualRegressionTesting, ), 'process.env.BUILT_FOR_PLAYGROUND': JSON.stringify('true'), }, banner: liveReload?.esbuildBanner, // watch: liveReload?.esbuildWatch && { // onRebuild(error, result) { // esbuildWatchStop = result?.stop ?? esbuildWatchStop // liveReload?.esbuildWatch.onRebuild?.(error, result) // }, // }, plugins: [ { name: 'watch playground assets', setup(build) { build.onStart(() => {}) build.onLoad( { filter: /index\.tsx?$/, }, async (loadFile) => { const indexHtmlPath = loadFile.path.replace( /index\.tsx?$/, 'index.html', ) const relToSrc = path.relative(srcDir, indexHtmlPath) const isInSrcFolder = !relToSrc.startsWith('..') if (isInSrcFolder) { const newHtml = await readFile(indexHtmlPath, 'utf-8').catch( () => undefined, ) if (newHtml) { await writeFile( path.resolve(buildDir, relToSrc), newHtml.replace( /<\/body>/, ``, ), ).catch( wrapCatch( `loading index.tsx creates corresponding index.html for ${relToSrc}`, ), ) } return { watchFiles: [indexHtmlPath], } } }, ) }, }, ], } const ctx = await esbuild.context(esbuildConfig) if (liveReload) { await ctx.watch() } else { await ctx.rebuild() } // Read index.html template const index = await readFile( path.join(__dirname, 'index.html'), 'utf8', ).catch(wrapCatch('reading index.html template')) await Promise.all([ // Write home page writeFile( path.join(buildDir, 'index.html'), index .replace(/<\/head>/, `${homeHtml.head}<\/head>`) .replace(//, `${homeHtml.html}`), 'utf-8', ).catch(wrapCatch('writing build index.html')), // Write module pages ...outModules.map((outModule) => writeFile( path.join(outModule.outDir, 'index.html'), // Insert the script (outModule.useHtml ?? index).replace( /<\/body>/, ``, ), 'utf-8', ).catch( wrapCatch( `writing index.html for ${path.relative(buildDir, outModule.outDir)}`, ), ), ), ]) // Only start dev server in serve, otherwise just run build and that's it if (!options.serve) { await ctx.dispose() return } const {serve} = options // We start ESBuild serve with no build config because it doesn't need to build // anything, we are already using ESBuild watch. /** See https://esbuild.github.io/api/#serve-return-values */ const esbuildServe = await ctx.serve({servedir: buildDir}) const proxyServer = createProxyServer(liveReload?.handleRequest, { hostname: '0.0.0.0', port: esbuildServe.port, }) // const proxyForceExit = createServerForceClose(proxyServer) const portTries = serve.findAvailablePort ? 10 : 1 const portChosen = await tryMultiplePorts(defaultPort, portTries, proxyServer) const hostedAt = `http://localhost:${portChosen}` console.log('Playground running at', hostedAt) if (serve.openBrowser) { setTimeout(() => { if (!liveReload?.hasOpenConnections()) openForOS(hostedAt) }, 1000) } // return { // async stop() { // esbuildWatchStop?.() // await proxyForceExit() // }, // } } function wrapCatch(message: string) { return (err: any) => { return Promise.reject(`Rejected "${message}":\n ${err.toString()}`) } } const dev = process.argv.find((arg) => ['--dev', '-d'].includes(arg)) != null const serve = process.argv.find((arg) => ['--serve'].includes(arg)) != null || undefined const isCI = Boolean(process.env.CI) start({ dev: !isCI && dev, isVisualRegressionTesting: isCI, serve: serve && { findAvailablePort: !isCI, // If not in CI, try to spawn a browser openBrowser: !isCI, // waitBeforeStartingServer: current?.stop(), }, }).then( () => {}, (err) => { console.error(err) process.exit(1) }, )