Skip to content

Commit 54d9caa

Browse files
authored
Add WordPress Playground DevTools browser extension (#3056)
## Summary This PR adds a new Chrome extension that provides a DevTools panel for inspecting and editing WordPress Playground instances running on any webpage. - Detects all frames where `window.playground` is defined - Automatically refreshes the list every second to catch dynamically added instances - Auto-selects if there's only one playground instance on the page - Provides a file browser and code editor for browsing and editing files in the Playground filesystem The extension uses Manifest V3 with `chrome.scripting.executeScript` using `world: 'MAIN'` to bypass CSP restrictions when detecting playground instances. It creates a filesystem proxy over Chrome extension messaging to call PHP methods on the detected playground instance. This PR also extracts common editor components (CodeEditor, FileExplorerSidebar, SiteEditor) to the shared `@wp-playground/components` package for reuse between the website and the extension. <img width="3150" height="2646" alt="CleanShot 2025-12-16 at 23 27 23@2x" src="https://github.com/user-attachments/assets/b28994e9-9e8f-4260-ada1-63f09d74fa77" /> ## Follow-up work * Publish the extension * Make it easy to learn about and install * Consider also bundling adminer for database management ## Test plan - [ ] Build the extension with `npx nx build playground-devtools-extension` - [ ] Load the extension in Chrome from `dist/packages/playground/devtools-extension` - [ ] Open a page with WordPress Playground (e.g., playground.wordpress.net) - [ ] Open DevTools and verify the "Playground" tab appears - [ ] Verify that the file browser shows the WordPress filesystem - [ ] Test file editing and verify changes are saved
1 parent 66301fe commit 54d9caa

35 files changed

+2283
-657
lines changed

package-lock.json

Lines changed: 149 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/php-wasm/web/src/lib/load-runtime.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ export async function loadWebRuntime(
5858
* https://github.com/emscripten-core/emscripten/blob/6d61ffd7076309cb08af37aba496f25c23cdb5a4/src/lib/libeventloop.js#L57
5959
*/
6060
if (!('setImmediate' in globalThis)) {
61-
(globalThis as PHPWorkerGlobalScope).setImmediate = (fn: () => void) =>
62-
setTimeout(fn, 0);
61+
(globalThis as unknown as PHPWorkerGlobalScope).setImmediate = (
62+
fn: () => void
63+
) => setTimeout(fn, 0);
6364
}
6465

6566
let emscriptenOptions: EmscriptenOptions | Promise<EmscriptenOptions> = {

packages/playground/components/package.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,19 @@
2525
"engines": {
2626
"node": ">=20.18.3",
2727
"npm": ">=10.1.0"
28+
},
29+
"peerDependencies": {
30+
"@codemirror/autocomplete": "^6.18.6",
31+
"@codemirror/commands": "^6.8.0",
32+
"@codemirror/lang-css": "^6.3.1",
33+
"@codemirror/lang-html": "^6.4.9",
34+
"@codemirror/lang-javascript": "^6.2.4",
35+
"@codemirror/lang-json": "^6.0.1",
36+
"@codemirror/lang-markdown": "^6.3.2",
37+
"@codemirror/lang-php": "^6.0.1",
38+
"@codemirror/language": "^6.11.0",
39+
"@codemirror/search": "^6.5.10",
40+
"@codemirror/state": "^6.5.2",
41+
"@codemirror/view": "^6.36.2"
2842
}
2943
}

packages/playground/website/src/components/site-manager/site-file-browser/code-editor.tsx renamed to packages/playground/components/src/PlaygroundFileEditor/code-editor.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {
1+
import React, {
22
forwardRef,
33
useEffect,
44
useImperativeHandle,

packages/playground/website/src/components/site-manager/site-file-browser/file-explorer-sidebar.tsx renamed to packages/playground/components/src/PlaygroundFileEditor/file-explorer-sidebar.tsx

Lines changed: 15 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {
1+
import React, {
22
useMemo,
33
useRef,
44
useState,
@@ -16,48 +16,13 @@ import type { AsyncWritableFilesystem } from '@wp-playground/storage';
1616
import { logger } from '@php-wasm/logger';
1717
import { dirname, normalizePath } from '@php-wasm/util';
1818
import { BinaryFilePreview } from '@wp-playground/components';
19-
import mimeTypes from '@php-wasm/universal/mime-types';
20-
21-
export const MAX_INLINE_FILE_BYTES = 1024 * 1024; // 1MB
22-
23-
const seemsLikeBinary = (buffer: Uint8Array) => {
24-
// Assume that anything with a null byte in the first 4096 bytes is binary.
25-
// This isn't a perfect test, but it catches a lot of binary files.
26-
const len = buffer.byteLength;
27-
for (let i = 0; i < Math.min(len, 4096); i++) {
28-
if (buffer[i] === 0) {
29-
return true;
30-
}
31-
}
32-
33-
// Next, try to decode the buffer as UTF-8. If it fails, it's probably binary.
34-
try {
35-
new TextDecoder('utf-8', { fatal: true }).decode(buffer);
36-
return false;
37-
} catch {
38-
return true;
39-
}
40-
};
41-
42-
const createDownloadUrl = (data: Uint8Array, filename: string) => {
43-
const blob = new Blob([data]);
44-
const url = URL.createObjectURL(blob);
45-
setTimeout(() => URL.revokeObjectURL(url), 60_000);
46-
return { url, filename };
47-
};
48-
49-
const getMimeType = (filename: string): string => {
50-
const extension = filename.split('.').pop() as keyof typeof mimeTypes;
51-
return mimeTypes[extension] || mimeTypes['_default'];
52-
};
53-
54-
const isPreviewableBinary = (mimeType: string): boolean => {
55-
return (
56-
mimeType.startsWith('image/') ||
57-
mimeType.startsWith('video/') ||
58-
mimeType.startsWith('audio/')
59-
);
60-
};
19+
import {
20+
MAX_INLINE_FILE_BYTES,
21+
seemsLikeBinary,
22+
createDownloadUrl,
23+
getMimeType,
24+
isPreviewableBinary,
25+
} from './file-utils';
6126

6227
export type FileExplorerSidebarProps = {
6328
filesystem: AsyncWritableFilesystem;
@@ -178,12 +143,12 @@ export function FileExplorerSidebar({
178143
};
179144

180145
return (
181-
<div className={styles.fileExplorerContainer}>
182-
<div className={styles.fileExplorerHeader}>
183-
<span className={styles.fileExplorerTitle}>Files</span>
184-
<div className={styles.fileExplorerActions}>
146+
<div className={styles['fileExplorerContainer']}>
147+
<div className={styles['fileExplorerHeader']}>
148+
<span className={styles['fileExplorerTitle']}>Files</span>
149+
<div className={styles['fileExplorerActions']}>
185150
<button
186-
className={styles.fileExplorerButton}
151+
className={styles['fileExplorerButton']}
187152
type="button"
188153
onClick={() => {
189154
if (!treeRef.current) {
@@ -199,7 +164,7 @@ export function FileExplorerSidebar({
199164
New File
200165
</button>
201166
<button
202-
className={styles.fileExplorerButton}
167+
className={styles['fileExplorerButton']}
203168
type="button"
204169
onClick={() => {
205170
if (!treeRef.current) {
@@ -216,7 +181,7 @@ export function FileExplorerSidebar({
216181
</button>
217182
</div>
218183
</div>
219-
<div className={styles.fileExplorerTree}>
184+
<div className={styles['fileExplorerTree']}>
220185
<FilePickerTree
221186
ref={treeRef}
222187
filesystem={filesystem}

packages/playground/website/src/components/site-manager/site-file-browser/file-explorer.module.css renamed to packages/playground/components/src/PlaygroundFileEditor/file-explorer.module.css

File renamed without changes.

0 commit comments

Comments
 (0)