diff --git a/package.json b/package.json
index 6ddd8b9fe2..16c21066d6 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"build:website": "nx build playground-website",
"deploy": "gh-pages -d dist/docs -t true",
"dev": "nx dev playground-website",
+ "dev:interactivity": "nx dev interactive-block-playground",
"lint": "nx run-many --all --target=lint",
"predeploy": "nx build docs-site",
"prepublishOnly": "npm run build",
diff --git a/packages/playground/interactive-block-playground/README.md b/packages/playground/interactive-block-playground/README.md
new file mode 100644
index 0000000000..8518ff528a
--- /dev/null
+++ b/packages/playground/interactive-block-playground/README.md
@@ -0,0 +1,5 @@
+### Interactive Block Playground
+
+Built with [WordPress Playground](https://github.com/WordPress/wordpress-playground).
+
+https://user-images.githubusercontent.com/5417266/233141638-b8143576-fb56-462d-9abb-fce117ba84ba.mov
diff --git a/packages/playground/interactive-block-playground/build-zips.cjs b/packages/playground/interactive-block-playground/build-zips.cjs
new file mode 100644
index 0000000000..6357131d7b
--- /dev/null
+++ b/packages/playground/interactive-block-playground/build-zips.cjs
@@ -0,0 +1,48 @@
+const chokidar = require('chokidar');
+const archiver = require('archiver');
+const fs = require('fs');
+
+const helloFolderPath = './hello';
+const outputZipPath = './zips/hello.zip';
+
+// Function to zip the 'hello' folder and save it as 'hello.zip'
+function zipHelloFolder() {
+ const output = fs.createWriteStream(outputZipPath);
+ const archive = archiver('zip', {
+ zlib: { level: 9 }, // Sets the compression level.
+ });
+
+ output.on('close', () => {
+ console.log(`hello.zip has been created.`);
+ });
+
+ archive.on('error', (err) => {
+ throw err;
+ });
+
+ archive.pipe(output);
+
+ archive.directory(helloFolderPath, false);
+
+ archive.finalize();
+}
+
+// Watch the 'hello' folder for changes
+const watcher = chokidar.watch(helloFolderPath, {
+ persistent: true,
+ ignoreInitial: true,
+});
+
+watcher
+ .on('add', (path) => {
+ console.log(`File ${path} has been added.`);
+ zipHelloFolder();
+ })
+ .on('change', (path) => {
+ console.log(`File ${path} has been changed.`);
+ zipHelloFolder();
+ })
+ .on('unlink', (path) => {
+ console.log(`File ${path} has been removed.`);
+ zipHelloFolder();
+ });
diff --git a/packages/playground/interactive-block-playground/hello/blocks/hello/block.json b/packages/playground/interactive-block-playground/hello/blocks/hello/block.json
new file mode 100644
index 0000000000..01b9c78016
--- /dev/null
+++ b/packages/playground/interactive-block-playground/hello/blocks/hello/block.json
@@ -0,0 +1,18 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 2,
+ "name": "hello/log-block",
+ "version": "0.1.0",
+ "title": "Hello - Log block",
+ "category": "text",
+ "icon": "heart",
+ "description": "",
+ "textdomain": "hello",
+ "supports": {
+ "interactivity": true
+ },
+ "editorScript": "file:./index.js",
+ "render": "file:./render.php",
+ "viewScript": "file:./view.js",
+ "style": "file:./style.css"
+}
diff --git a/packages/playground/interactive-block-playground/hello/blocks/hello/index.asset.php b/packages/playground/interactive-block-playground/hello/blocks/hello/index.asset.php
new file mode 100644
index 0000000000..c4a34c8173
--- /dev/null
+++ b/packages/playground/interactive-block-playground/hello/blocks/hello/index.asset.php
@@ -0,0 +1 @@
+ array('wp-block-editor', 'wp-blocks', 'wp-element'), 'version' => '3cf3fb8a26c0120e24db');
diff --git a/packages/playground/interactive-block-playground/hello/blocks/hello/index.js b/packages/playground/interactive-block-playground/hello/blocks/hello/index.js
new file mode 100644
index 0000000000..4ed8e11869
--- /dev/null
+++ b/packages/playground/interactive-block-playground/hello/blocks/hello/index.js
@@ -0,0 +1,16 @@
+const { registerBlockType } = wp.blocks;
+const { useBlockProps } = wp.blockEditor;
+const { createElement } = wp.element;
+
+const Edit = () => {
+ return createElement(
+ 'p',
+ useBlockProps(),
+ 'Hello World! (from the editor).'
+ );
+};
+
+registerBlockType('hello/log-block', {
+ edit: Edit,
+ save: () => null,
+});
diff --git a/packages/playground/interactive-block-playground/hello/blocks/hello/render.php b/packages/playground/interactive-block-playground/hello/blocks/hello/render.php
new file mode 100644
index 0000000000..516089a565
--- /dev/null
+++ b/packages/playground/interactive-block-playground/hello/blocks/hello/render.php
@@ -0,0 +1,14 @@
+
+
+
+>
+
+
diff --git a/packages/playground/interactive-block-playground/hello/blocks/hello/style.css b/packages/playground/interactive-block-playground/hello/blocks/hello/style.css
new file mode 100644
index 0000000000..b5b90fff0f
--- /dev/null
+++ b/packages/playground/interactive-block-playground/hello/blocks/hello/style.css
@@ -0,0 +1,17 @@
+body > :not(.wp-site-blocks) {
+ display: none;
+}
+
+.wp-site-blocks > :not(main) {
+ display: none;
+}
+
+main > :not(.entry-content) {
+ display: none;
+}
+
+.entry-content {
+ position: absolute;
+ top: 0;
+ left: 0;
+}
diff --git a/packages/playground/interactive-block-playground/hello/blocks/hello/view.asset.php b/packages/playground/interactive-block-playground/hello/blocks/hello/view.asset.php
new file mode 100644
index 0000000000..9cb4f18936
--- /dev/null
+++ b/packages/playground/interactive-block-playground/hello/blocks/hello/view.asset.php
@@ -0,0 +1 @@
+ array(), 'version' => '8ff3477eebdb6c7f40ea');
diff --git a/packages/playground/interactive-block-playground/hello/blocks/hello/view.js b/packages/playground/interactive-block-playground/hello/blocks/hello/view.js
new file mode 100644
index 0000000000..659d5f09d2
--- /dev/null
+++ b/packages/playground/interactive-block-playground/hello/blocks/hello/view.js
@@ -0,0 +1,12 @@
+// Disclaimer: Importing the `store` using a global is just a temporary solution.
+const { store } = window.__experimentalInteractivity;
+
+store({
+ actions: {
+ hello: {
+ log: () => {
+ console.log('hello!');
+ },
+ },
+ },
+});
diff --git a/packages/playground/interactive-block-playground/hello/hello.php b/packages/playground/interactive-block-playground/hello/hello.php
new file mode 100644
index 0000000000..1046e0afc7
--- /dev/null
+++ b/packages/playground/interactive-block-playground/hello/hello.php
@@ -0,0 +1,61 @@
+query( $handle, 'registered' );
+ if ( ! $script ) {
+ return false;
+ }
+
+ if ( ! in_array( $dep, $script->deps, true ) ) {
+ $script->deps[] = $dep;
+
+ if ( $in_footer ) {
+ // move script to the footer
+ $wp_scripts->add_data( $handle, 'group', 1 );
+ }
+ }
+
+ return true;
+}
+
+function hello_auto_inject_interactivity_dependency() {
+ $registered_blocks = \WP_Block_Type_Registry::get_instance()->get_all_registered();
+
+ foreach ( $registered_blocks as $name => $block ) {
+ $has_interactivity_support = $block->supports['interactivity'] ?? false;
+
+ if ( ! $has_interactivity_support ) {
+ continue;
+ }
+ foreach ( $block->view_script_handles as $handle ) {
+ add_hello_script_dependency( $handle, 'wp-directive-runtime', true );
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/playground/interactive-block-playground/index.css b/packages/playground/interactive-block-playground/index.css
new file mode 100644
index 0000000000..3bb3ece520
--- /dev/null
+++ b/packages/playground/interactive-block-playground/index.css
@@ -0,0 +1,17 @@
+.editors {
+ display: grid;
+ grid-template-rows: 35px 2fr;
+ grid-auto-flow: column;
+ grid-gap: 20px;
+ justify-content: start;
+}
+
+textarea {
+ width: 387px;
+ height: 240px;
+ font-family: monospace;
+}
+
+.iframe-container {
+ position: relative;
+}
diff --git a/packages/playground/interactive-block-playground/index.html b/packages/playground/interactive-block-playground/index.html
new file mode 100644
index 0000000000..40fd242b0a
--- /dev/null
+++ b/packages/playground/interactive-block-playground/index.html
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+ Interactivity API - Interactive docs
+
+
+
+
+
+
render.php
+
+ view.js
+
+
+
+
+
diff --git a/packages/playground/interactive-block-playground/package.json b/packages/playground/interactive-block-playground/package.json
new file mode 100644
index 0000000000..a2c9ca1a88
--- /dev/null
+++ b/packages/playground/interactive-block-playground/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@wp-playground/interactive-block-playground",
+ "version": "1.0.0",
+ "description": "",
+ "scripts": {
+ "build-zips": "node build-zips.js"
+ },
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "type": "module",
+ "private": "true"
+}
diff --git a/packages/playground/interactive-block-playground/project.json b/packages/playground/interactive-block-playground/project.json
new file mode 100644
index 0000000000..a39a6b2218
--- /dev/null
+++ b/packages/playground/interactive-block-playground/project.json
@@ -0,0 +1,62 @@
+{
+ "name": "interactive-block-playground",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "packages/playground/interactive-block-playground/src",
+ "projectType": "application",
+ "implicitDependencies": ["playground-remote"],
+ "targets": {
+ "build:standalone": {
+ "executor": "@nrwl/vite:build",
+ "outputs": ["{options.outputPath}"],
+ "defaultConfiguration": "production",
+ "options": {
+ "outputPath": "dist/packages/playground/interactive-block-playground"
+ },
+ "configurations": {
+ "development": {
+ "mode": "development"
+ },
+ "production": {
+ "mode": "production"
+ }
+ }
+ },
+ "dev": {
+ "executor": "nx:run-commands",
+ "options": {
+ "commands": [
+ "nx dev playground-remote --configuration=development-for-website",
+ "nx dev:standalone interactive-block-playground --hmr --output-style=stream-without-prefixes"
+ ],
+ "parallel": true,
+ "color": true
+ }
+ },
+ "dev:standalone": {
+ "executor": "@nrwl/vite:dev-server",
+ "defaultConfiguration": "development",
+ "options": {
+ "buildTarget": "interactive-block-playground:build"
+ },
+ "configurations": {
+ "development": {
+ "buildTarget": "interactive-block-playground:build:standalone:development",
+ "hmr": true
+ },
+ "production": {
+ "buildTarget": "interactive-block-playground:build:standalone:production",
+ "hmr": false
+ }
+ }
+ },
+ "typecheck": {
+ "executor": "@nrwl/workspace:run-commands",
+ "options": {
+ "commands": [
+ "yarn tsc -p packages/playground/interactive-block-playground/tsconfig.json --noEmit"
+ ]
+ }
+ }
+ },
+ "tags": ["scope:web-client"]
+}
diff --git a/packages/playground/interactive-block-playground/src/config.ts b/packages/playground/interactive-block-playground/src/config.ts
new file mode 100644
index 0000000000..c2abc7fde0
--- /dev/null
+++ b/packages/playground/interactive-block-playground/src/config.ts
@@ -0,0 +1 @@
+export { remotePlaygroundOrigin } from 'virtual:interactive-block-playground-config';
diff --git a/packages/playground/interactive-block-playground/src/index.ts b/packages/playground/interactive-block-playground/src/index.ts
new file mode 100644
index 0000000000..37ed20dcf2
--- /dev/null
+++ b/packages/playground/interactive-block-playground/src/index.ts
@@ -0,0 +1,119 @@
+import { phpVars, startPlaygroundWeb } from '@wp-playground/client';
+
+import { remotePlaygroundOrigin } from './config';
+
+// Set the text content of the render.php element
+document.getElementById('render.php')!.textContent = `
+
+>
+
+
+`;
+
+// Set the text content of the view.js
+document.getElementById(
+ 'view.js'
+)!.textContent = `// Disclaimer: Importing the "store" using a global is just a temporary solution.
+const { store } = window.__experimentalInteractivity;
+
+store({
+ actions: {
+ hello: {
+ log: () => {
+ console.log("hello!");
+ },
+ },
+ },
+});`;
+
+(async () => {
+ const playground = document.querySelector(
+ '#playground'
+ ) as HTMLIFrameElement;
+
+ const js = phpVars({
+ title: 'Test post',
+ content: '',
+ });
+
+ const client = await startPlaygroundWeb({
+ iframe: playground,
+ remoteUrl: `${remotePlaygroundOrigin}/remote.html`,
+ blueprint: {
+ landingPage: '/wp-admin',
+ preferredVersions: {
+ php: '8.0',
+ wp: 'latest',
+ },
+ steps: [
+ {
+ step: 'login',
+ username: 'admin',
+ password: 'password',
+ },
+ {
+ step: 'installPlugin',
+ pluginZipFile: {
+ resource: 'wordpress.org/plugins',
+ slug: 'gutenberg',
+ },
+ },
+ {
+ step: 'installPlugin',
+ pluginZipFile: {
+ resource: 'url',
+ url: 'zips/hello.zip',
+ },
+ },
+ {
+ step: 'runPHP',
+ code: ` ${js.title},
+ "post_content" => ${js.content},
+ "post_status" => "publish",
+ ]);
+ file_put_contents('/post-id.txt', $post_id);
+ `,
+ },
+ {
+ step: 'installPlugin',
+ pluginZipFile: {
+ resource: 'url',
+ url: '/plugin-proxy?repo=WordPress/block-interactivity-experiments&name=block-interactivity-experiments.zip',
+ },
+ },
+ ],
+ },
+ });
+
+ const postId = await client.readFileAsText('/post-id.txt');
+ await client.goTo(`/?p=${postId}`);
+
+ document
+ .getElementById('render.php')!
+ .addEventListener('keyup', async (e) => {
+ client.writeFile(
+ '/wordpress/wp-content/plugins/hello/blocks/hello/render.php',
+ (e.target as HTMLTextAreaElement).value
+ );
+
+ await client.goTo(`/?p=${postId}`);
+ });
+
+ document.getElementById('view.js')!.addEventListener('keyup', async (e) => {
+ client.writeFile(
+ '/wordpress/wp-content/plugins/hello/blocks/hello/view.js',
+ (e.target as HTMLTextAreaElement).value
+ );
+
+ await client.goTo(`/?p=${postId}`);
+ });
+})();
diff --git a/packages/playground/interactive-block-playground/src/types.d.ts b/packages/playground/interactive-block-playground/src/types.d.ts
new file mode 100644
index 0000000000..602b204f26
--- /dev/null
+++ b/packages/playground/interactive-block-playground/src/types.d.ts
@@ -0,0 +1,3 @@
+declare module 'virtual:interactive-block-playground-config' {
+ export const remotePlaygroundOrigin: string;
+}
diff --git a/packages/playground/interactive-block-playground/tsconfig.app.json b/packages/playground/interactive-block-playground/tsconfig.app.json
new file mode 100644
index 0000000000..06d48f3ea3
--- /dev/null
+++ b/packages/playground/interactive-block-playground/tsconfig.app.json
@@ -0,0 +1,22 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "types": ["node", "vite/client"]
+ },
+ "files": [
+ "../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
+ "../../../node_modules/@nrwl/react/typings/image.d.ts"
+ ],
+ "exclude": [
+ "src/**/*.spec.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.js",
+ "src/**/*.test.js",
+ "src/**/*.spec.jsx",
+ "src/**/*.test.jsx"
+ ],
+ "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/packages/playground/interactive-block-playground/tsconfig.json b/packages/playground/interactive-block-playground/tsconfig.json
new file mode 100644
index 0000000000..ae44604ae9
--- /dev/null
+++ b/packages/playground/interactive-block-playground/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "allowJs": false,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "types": ["vite/client", "vitest", "react"]
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.spec.json"
+ }
+ ],
+ "extends": "../../../tsconfig.base.json"
+}
diff --git a/packages/playground/interactive-block-playground/tsconfig.spec.json b/packages/playground/interactive-block-playground/tsconfig.spec.json
new file mode 100644
index 0000000000..2b7ee41aec
--- /dev/null
+++ b/packages/playground/interactive-block-playground/tsconfig.spec.json
@@ -0,0 +1,23 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node"]
+ },
+ "include": [
+ "vite.config.ts",
+ "src/**/*.test.ts",
+ "src/**/*.spec.ts",
+ "src/**/*.test.tsx",
+ "src/**/*.spec.tsx",
+ "src/**/*.test.js",
+ "src/**/*.spec.js",
+ "src/**/*.test.jsx",
+ "src/**/*.spec.jsx",
+ "src/**/*.d.ts"
+ ],
+ "files": [
+ "../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
+ "../../../node_modules/@nrwl/react/typings/image.d.ts"
+ ]
+}
diff --git a/packages/playground/interactive-block-playground/vite.config.ts b/packages/playground/interactive-block-playground/vite.config.ts
new file mode 100644
index 0000000000..a2c3cdac2a
--- /dev/null
+++ b/packages/playground/interactive-block-playground/vite.config.ts
@@ -0,0 +1,79 @@
+///
+import { defineConfig } from 'vite';
+import viteTsConfigPaths from 'vite-tsconfig-paths';
+// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries
+import {
+ websiteDevServerHost,
+ websiteDevServerPort,
+ remoteDevServerHost,
+ remoteDevServerPort,
+} from '../build-config';
+// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries
+import virtualModule from '../vite-virtual-module';
+
+const proxy = {
+ '^/plugin-proxy.*repo=.*': {
+ target: 'https://playground.wordpress.net',
+ changeOrigin: true,
+ secure: true,
+ },
+ '/plugin-proxy': {
+ target: 'https://downloads.wordpress.org',
+ changeOrigin: true,
+ secure: true,
+ rewrite: (path: string) => {
+ const url = new URL(path, 'http://example.com');
+ if (url.searchParams.has('plugin')) {
+ return `/plugin/${url.searchParams.get('plugin')}`;
+ } else if (url.searchParams.has('theme')) {
+ return `/theme/${url.searchParams.get('theme')}`;
+ }
+ throw new Error('Invalid request');
+ },
+ },
+};
+
+export default defineConfig(({ command }) => {
+ const playgroundOrigin =
+ command === 'build'
+ ? // In production, both the website and the playground are served from the same domain.
+ ''
+ : // In dev, the website and the playground are served from different domains.
+ `http://${remoteDevServerHost}:${remoteDevServerPort}`;
+ return {
+ cacheDir:
+ '../../../node_modules/.vite/packages-interactive-block-playground',
+
+ preview: {
+ port: websiteDevServerPort,
+ host: websiteDevServerHost,
+ headers: {
+ 'Cross-Origin-Resource-Policy': 'cross-origin',
+ 'Cross-Origin-Embedder-Policy': 'credentialless',
+ },
+ proxy,
+ },
+
+ server: {
+ port: websiteDevServerPort,
+ host: websiteDevServerHost,
+ headers: {
+ 'Cross-Origin-Resource-Policy': 'cross-origin',
+ 'Cross-Origin-Embedder-Policy': 'credentialless',
+ },
+ proxy,
+ },
+
+ plugins: [
+ viteTsConfigPaths({
+ root: '../../../',
+ }),
+ virtualModule({
+ name: 'interactive-block-playground-config',
+ content: `export const remotePlaygroundOrigin = ${JSON.stringify(
+ playgroundOrigin
+ )};`,
+ }),
+ ],
+ };
+});
diff --git a/packages/playground/interactive-block-playground/zips/block-interactivity-experiments.zip b/packages/playground/interactive-block-playground/zips/block-interactivity-experiments.zip
new file mode 100644
index 0000000000..4853916d9e
Binary files /dev/null and b/packages/playground/interactive-block-playground/zips/block-interactivity-experiments.zip differ
diff --git a/packages/playground/interactive-block-playground/zips/hello.zip b/packages/playground/interactive-block-playground/zips/hello.zip
new file mode 100644
index 0000000000..7c8dc6701d
Binary files /dev/null and b/packages/playground/interactive-block-playground/zips/hello.zip differ
diff --git a/packages/playground/website/public/plugin-proxy.php b/packages/playground/website/public/plugin-proxy.php
index e61c43d807..2dacf0b4c8 100644
--- a/packages/playground/website/public/plugin-proxy.php
+++ b/packages/playground/website/public/plugin-proxy.php
@@ -112,6 +112,25 @@ public function streamFromGithubPR($organization, $repo, $pr, $workflow_name, $a
}
}
+ public function streamFromGithubReleases($repo, $name)
+ {
+ $zipUrl = "https://github.com/$repo/releases/latest/download/$name";
+ try {
+ $this->streamHttpResponse($zipUrl, [
+ 'content-length',
+ 'x-frame-options',
+ 'last-modified',
+ 'etag',
+ 'date',
+ 'age',
+ 'vary',
+ 'cache-Control'
+ ]);
+ } catch (ApiException $e) {
+ throw new ApiException("Plugin or theme '$name' not found");
+ }
+ }
+
protected function gitHubRequest($url, $decode = true)
{
$headers[] = 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36';
@@ -247,6 +266,15 @@ function ($curl, $body) use (&$extra_headers_sent, $default_headers) {
$_GET['workflow'],
$_GET['artifact']
);
+ } else if (isset($_GET['repo']) && isset($_GET['name'])) {
+
+ // Only allow downloads from the block-interactivity-experiments repo for now.
+ if ($_GET['repo'] !== 'WordPress/block-interactivity-experiments') {
+ throw new ApiException('Invalid repo. Only "WordPress/block-interactivity-experiments" is allowed.');
+ }
+
+ $downloader->streamFromGithubReleases($_GET['repo'], $_GET['name']);
+
} else {
throw new ApiException('Invalid query parameters');
}
@@ -256,4 +284,4 @@ function ($curl, $body) use (&$extra_headers_sent, $default_headers) {
header('Content-Type: application/json');
}
die(json_encode(['error' => $e->getMessage()]));
-}
\ No newline at end of file
+}