Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow file editing and updates the preview #762

Draft
wants to merge 22 commits into
base: better-playground
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions apps/docs/components/Playground.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/

import BrowserOnly from '@docusaurus/BrowserOnly';
import * as stylex from '@stylexjs/stylex';
import { WebContainer } from '@webcontainer/api';
import * as React from 'react';
import { useEffect, useRef, useState } from 'react';
import { UnControlled as CodeMirror } from 'react-codemirror2';
import { files } from './playground-utils/files';

async function wcSpawn(instance, ...args) {
console.log('Running:', args.join(' '));
const process = await instance.spawn(...args);
process.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
},
}),
);
const exitCode = await process.exit;
if (exitCode !== 0) {
console.log('Command Failed:', args.join(' '), 'with exit code', exitCode);
throw new Error('Command Failed', args.join(' '));
}

console.log('Command Successful:', args.join(' '));
return process;
}

async function makeWebcontainer() {
console.log('Booting WebContainer...');
const instance = await WebContainer.boot();
console.log('Boot successful!');

console.log('Mounting files...');
await instance.mount(files);
console.log('Mounted files!');

console.log('Installing dependencies...');
await wcSpawn(instance, 'npm', ['install']);
console.log('Installed dependencies!');

return instance;
}

export default function Playground() {
const instance = useRef(null);
const [url, setUrl] = useState(null);
const debounceTimeout = useRef(null);
const [code, setCode] = useState(
files.src.directory['app.jsx'].file.contents,
);

const build = async () => {
const containerInstance = instance.current;
if (!containerInstance) return;

console.log('Trying to run `npm start`...');
const process = await containerInstance.spawn('npm', ['start']);
console.log('Spawned `npm start`...');
process.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
},
}),
);

console.log('Waiting for server-ready event...');
containerInstance.on('server-ready', (port, url) => {
console.log('server-ready', port, url);
setUrl(url);
});
};

const updateFiles = async () => {
const containerInstance = instance.current;
const filePath = './src/app.jsx';
const updatedCode = code;
await containerInstance.fs.writeFile(filePath, updatedCode);
await wcSpawn(containerInstance, 'node', ['generateCSS.js']);
6ri4n marked this conversation as resolved.
Show resolved Hide resolved
};

const handleCodeChange = (newCode) => {
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
6ri4n marked this conversation as resolved.
Show resolved Hide resolved

debounceTimeout.current = setTimeout(async () => {
setCode((prevCode) => newCode);

Check failure on line 98 in apps/docs/components/Playground.js

View workflow job for this annotation

GitHub Actions / lint

'prevCode' is defined but never used. Allowed unused args must match /^_/u
6ri4n marked this conversation as resolved.
Show resolved Hide resolved
if (url) {
try {
await updateFiles();
console.log('Successfully applied changes.');
} catch (err) {
console.error(err);
}
}
}, 3000);
};

useEffect(() => {
require('codemirror/mode/javascript/javascript');
makeWebcontainer().then((i) => {
instance.current = i;
build();
});
() => {
instance.current.unmount();
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
};
}, []);

return (
<div {...stylex.props(styles.container)}>
<BrowserOnly>
{() => (
<>
<CodeMirror
{...stylex.props(styles.textarea)}
options={{
mode: 'javascript',
theme: 'material-darker',
lineNumbers: true,
}}
value={code}
onChange={(editor, data, newCode) => handleCodeChange(newCode)}

Check failure on line 137 in apps/docs/components/Playground.js

View workflow job for this annotation

GitHub Actions / lint

Props should be sorted alphabetically
/>
{url ? (
6ri4n marked this conversation as resolved.
Show resolved Hide resolved
<iframe {...stylex.props(styles.textarea)} src={url} />
) : (
<div {...stylex.props(styles.textarea)}>Loading...</div>
)}
</>
)}
</BrowserOnly>
</div>
);
}

const styles = stylex.create({
container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: stylex.firstThatWorks('calc(100dvh - 60px)', 'calc(100vh - 60px)'),
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: 'var(--cyan)',
},
textarea: {
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
justifyContent: 'stretch',
width: '50%',
height: '100%',
borderWidth: 0,
borderStyle: 'none',
},
});
210 changes: 210 additions & 0 deletions apps/docs/components/playground-utils/files.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
*/

export const files = {
'generateCSS.js': {
file: {
contents: `
const fs = require("fs/promises");
const { transformAsync } = require("@babel/core");
const stylexBabelPlugin = require("@stylexjs/babel-plugin");
const flowSyntaxPlugin = require("@babel/plugin-syntax-flow");
const jsxSyntaxPlugin = require("@babel/plugin-syntax-jsx");
const path = require("path");
const { mkdirp } = require("mkdirp");

async function transformFile(filePath) {
const code = await fs.readFile(filePath, "utf8");
const result = await transformAsync(code, {
filename: filePath,
plugins: [
flowSyntaxPlugin,
jsxSyntaxPlugin,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The StyleX plugin should already be doing this.

[
stylexBabelPlugin,
{
dev: false,
test: false,
stylexSheetName: "<>",
genConditionalClasses: true,
unstable_moduleResolution: {
type: "commonJS",
rootDir: path.join(__dirname),
},
},
],
],
sourceType: "unambiguous",
babelrc: false,
});
return result.metadata.stylex;
}

async function getAllFilesOfType(folder, type) {
const contents = await fs.readdir(folder, { withFileTypes: true });

const files = await Promise.all(
contents.map(async (dirent) => {
const subPath = path.join(folder, dirent.name);
if (dirent.isDirectory()) {
return await getAllFilesOfType(subPath, type);
}
if (dirent.name.endsWith(type)) {
return subPath;
}
return null;
})
);

return files.flat().filter(Boolean);
}

async function genSheet() {
const src = await getAllFilesOfType(path.join(__dirname, "src"), ".jsx");
const ruleSets = await Promise.all(src.map(transformFile));
const generatedCSS = stylexBabelPlugin.processStylexRules(ruleSets.flat());
const outputDir = path.join(__dirname, "src");
const cssPath = path.join(outputDir, "stylex.css");

await mkdirp(outputDir);
await fs.writeFile(cssPath, generatedCSS);

console.log("Successfully generated CSS.");
}

genSheet();
`,
},
},
'index.html': {
file: {
contents: `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stylex Playground</title>
</head>
<body>
<h1 style="color: blue">Loaded HTML from webcontainer!</h1>
<div id="root"></div>
<script type="module" src="./src/main.jsx"></script>
</body>
</html>
`,
},
},
'vite.config.js': {
file: {
contents: `
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
plugins: [
react({
babel: {
presets: ["@babel/preset-react"],
plugins: ["@stylexjs/babel-plugin"],
},
}),
],
server: {
port: 3111,
},
});
`,
},
},
'package.json': {
file: {
contents: `
{
"name": "stylex-playground",
"version": "1.0.0",
"description": "Playground using WebContainers",
"main": "index.js",
"scripts": {
"start": "node generateCSS.js && vite dev"
},
"dependencies": {
"@babel/cli": "latest",
"@babel/core": "latest",
"@babel/plugin-syntax-flow": "latest",
"@babel/plugin-syntax-jsx": "latest",
"@babel/plugin-syntax-typescript": "latest",
"@babel/preset-env": "^7.23.5",
"@babel/preset-react": "^7.23.3",
"@stylexjs/babel-plugin": "^0.8.0",
"@stylexjs/stylex": "^0.8.0",
"babel-plugin-transform-node-env-inline": "^0.4.3",
"react": "*",
"react-dom": "*",
"vite": "^5.4.10",
"@vitejs/plugin-react": "^4.3.3",
"mkdirp": "^3.0.1"
}
}
`,
},
},
src: {
directory: {
'main.jsx': {
file: {
contents: `
import * as React from "react";
import { createRoot } from "react-dom/client";
import Card from "./app.jsx";

const container = document.getElementById("root");
const root = createRoot(container);
root.render(<Card em={true}>Hello World!</Card>);
`,
},
},
'app.jsx': {
file: {
contents: `
import * as React from "react";
import * as stylex from "@stylexjs/stylex";
import "./stylex.css";

export default function Card({ children, em = false, props }) {
return (
<div {...props} {...stylex.props(styles.base, em && styles.emphasise)}>
{children}
</div>
);
}

const styles = stylex.create({
base: {
appearance: "none",
backgroundColor: "blue",
borderRadius: 4,
borderStyle: "none",
boxSize: "border-box",
color: "white",
marginInline: "auto",
paddingBlock: 4,
paddingInline: 8,
width: "95%",
},
emphasise: {
transform: "rotate(-2deg)",
},
});
`,
},
},
},
},
};
Loading
Loading