The PHP module has its own filesystem separate from your computer's filesystem. It is provided by Emscripten's FS library and the default APIs is low-level and cumbersome to use. The PHP JavaScript class shipped with WordPress Playground wraps it with a more convenient higher-level API.
5 |
6 |
7 |
8 |
In general, WordPress Playground uses an in-memory virtual filesystem.
9 |
10 |
11 |
12 |
However, in Node.js, you can also mount a real directory from the host filesystem into the PHP filesystem.
13 |
14 |
15 |
16 |
Here's how to interact with the filesystem in WordPress Playground:
17 |
18 |
19 |
20 |
// Recursively create a /var/www directory
21 | php.mkdirTree('/var/www');
22 |
23 | console.log(php.fileExists('/var/www/file.txt'));
24 | // false
25 |
26 | php.writeFile('/var/www/file.txt', 'Hello from the filesystem!');
27 |
28 | console.log(php.fileExists('/var/www/file.txt'));
29 | // true
30 |
31 | console.log(php.readFile('/var/www/file.txt'));
32 | // "Hello from the filesystem!
33 |
34 | // Delete the file:
35 | php.unlink('/var/www/file.txt');
36 |
37 |
38 |
39 |
For more details consult the BasePHP class directly – it has some great documentation strings.
25 |
26 |
27 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## WordPress Playground for Documenters
2 |
3 | This is an attempt to create a buildless Documentation Contributor Workflow that uses WordPress Playground to:
4 |
5 | * Fetch the latest version of the documentation from the repository.
6 | * Edit documentation in a browser-based editor.
7 | * Preview the changes in real-time.
8 | * Submit the changes as a pull request.
9 | * Provide a live preview of the documentation PR.
10 |
11 | [ Browse the Documentation ](https://adamziel.github.io/playground-docs-workflow/).
12 |
13 | ## How to edit the documentation?
14 |
15 | ### In WordPress Playground
16 |
17 | Click here to try it:
18 |
19 | [ Edit the Documentation ](https://playground.wordpress.net/?gh-ensure-auth=yes&ghexport-repo-url=https%3A%2F%2Fgithub.com%2Fadamziel%2Fplayground-docs-workflow&ghexport-content-type=custom-paths&ghexport-path=plugins/wp-docs-plugin&ghexport-path=plugins/export-static-site&ghexport-path=themes/playground-docs&ghexport-path=html-pages&ghexport-path=uploads&ghexport-commit-message=Documentation+update&ghexport-playground-root=/wordpress/wp-content&ghexport-repo-root=/wp-content&blueprint-url=https%3A%2F%2Fraw.githubusercontent.com%2Fadamziel%2Fplayground-docs-workflow%2Ftrunk%2Fblueprint-browser.json&ghexport-pr-action=create&ghexport-allow-include-zip=no).
20 |
21 | It should load the doc pages from the `html-pages` directory and the media attachments from `uploads`. This video demonstrates it:
22 |
23 | https://github.com/adamziel/playground-docs-workflow/assets/205419/5d06d8b8-cd9f-4cec-a8c6-e73d66e82159
24 |
25 | ### Locally
26 |
27 | To start a local server with the documentation site, run:
28 |
29 | ```bash
30 | bash start-server.sh
31 | ```
32 |
33 | You'll need node.js and npm installed.
34 |
35 | Once you're done editing the documentation, commit your changes as follows:
36 |
37 | ```bash
38 | git add wp-content
39 | git commit -a
40 | ```
41 |
42 | And then submit a Pull Request to the repository.
43 |
44 | ## How to edit the site theme?
45 |
46 | Adjust the site as needed in the site editor and then use the preinstalled [create-block-theme](https://github.com/WordPress/create-block-theme/) plugin to [save the theme updates](https://github.com/WordPress/create-block-theme/?tab=readme-ov-file#how-to-use-the-plugin) and propose them as a PR.
47 |
48 | ## Remaining work
49 |
50 | - [ ] Put preview links in the GitHub PRs
51 |
--------------------------------------------------------------------------------
/wp-content/themes/playground-docs/patterns/footer.php:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
Documentation', 'twentytwentyfour');?>
32 |
33 |
34 |
35 |
API Reference', 'twentytwentyfour');?>
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
GitHub', 'twentytwentyfour');?>
50 |
51 |
52 |
53 |
#meta-playground on Slack', 'twentytwentyfour');?>
However, vanilla PHP builds aren't very useful in the browser. As a server software, PHP doesn't have a JavaScript API to pass the request body, upload files, or populate the php://stdin stream. WordPress Playground had to build one from scratch. The WebAssembly binary comes with a dedicated PHP API module written in C and a JavaScript PHP class that exposes methods like writeFile() or run().
21 |
22 |
23 |
24 |
Because every PHP version is just a static .wasm file, the PHP version switcher is actually pretty boring. It simply tells the browser to download, for example, php_7_3.wasm instead of, say, php_8_2.wasm.
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
Networking support varies between platforms
33 |
34 |
35 |
36 |
When it comes to networking, WebAssembly programs are limited to calling JavaScript APIs. It is a safety feature, but also presents a challenge. How do you support low-level, synchronous networking code used by PHP with the high-level asynchronous APIs available in JavaScript?
37 |
38 |
39 |
40 |
In Node.js, the answer involves a WebSocket to TCP socket proxy, Asyncify, and patching deep PHP internals like php_select. It's complex, but there's a reward. The Node.js-targeted PHP build can request web APIs, install composer packages, and even connect to a MySQL server.
41 |
42 |
43 |
44 |
In the browser, networking is supported to a limited extent. Network calls initiated using wp_safe_remote_get, like the ones in the plugin directory or the font library, are translated into fetch() calls and succeed if the remote server sends the correct CORS headers. However, a full support for arbitrary HTTPS connection involves opening a raw TCP socket which is not possible in the browser. There is an open GitHub issue that explores possible ways of addressing this problem.
Asyncify lets synchronous C or C++ code interact with asynchronous JavaScript. Technically, it saves the entire C call stack before yielding control back to JavaScript, and then restores it when the asynchronous call is finished. This is called stack switching.
5 |
6 |
7 |
8 |
Networking support in the WebAssembly PHP build is implemented using Asyncify. When PHP makes a network request, it yields control back to JavaScript, which makes the request, and then resumes PHP when the response is ready. It works well enough that PHP build can request web APIs, install composer packages, and even connect to a MySQL server.
9 |
10 |
11 |
12 |
Asyncify crashes
13 |
14 |
15 |
16 |
Stack switching requires wrapping all C functions that may be found at a call stack at a time of making an asynchronous call. Blanket-wrapping of every single C function adds a significant overhead, which is why we maintain a list of specific function names:
Unfortunately, missing even a single item from that list results in a WebAssembly crash whenever that function is a part of the call stack when an asynchronous call is made. It looks like this:
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
Asyncify can auto-list all the required C functions when built without ASYNCIFY_ONLY, but that auto-detection is overeager and ends up listing about 70,000 C functions which increases the startup time to 4.5s. That's why we maintain the list manually.
Pull Request 253 adds a fix-asyncify command that runs a specialized test suite and automatically adds any identified missing C functions to the ASYNCIFY_ONLY list.
45 |
46 |
47 |
48 |
If you run into a crash like the one above, you can fix it by:
49 |
50 |
51 |
52 |
53 |
Identifying a PHP code path that triggers the crash – the stack trace in the terminal should help with that.
54 |
55 |
56 |
57 |
Adding a test case that triggers a crash to packages/php-wasm/node/src/test/php-asyncify.spec.ts
58 |
59 |
60 |
61 |
Running: npm run fix-asyncify
62 |
63 |
64 |
65 |
Committing the test case, the updated Dockerfile, and the rebuilt PHP.wasm
66 |
67 |
68 |
69 |
70 |
The upcoming JSPI API will make Asyncify unnecessary
The current implementation in V8 is essentially 'experimental status'. We have arm64 and x64 implementations. The next steps are to implement on 32 bit arm/intel. That requires us to solve some issues that we did not have to solve so far. As for node.js, my guess is that it is already in node, behind a flag. To remove the flag requirement involves getting other implementations. The best estimate for that is towards the end of this year; but it obviously depends on resources and funding. In addition, it would need further progress in the standardization effort; but, given that it is a 'small' spec, that should not be a long term burden. Hope that this helps you understand the roadmap :)
WordPress Playground website moved to wordpress.org/playground. The site you're at now hosts the documentation.
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
👋 Hi! Welcome to WordPress Playground documentation. Playground is an online tool to experiment and learn about WordPress – learn more in the [overview section](./02-overview.md).
19 |
20 |
21 |
22 |
The documentation consists of two major sections:
23 |
24 |
25 |
26 |
- [Documentation](./01-index.md) (you're here) – Introduction, concepts, and guides
27 |
28 |
29 |
30 |
- [API reference](/api) – All the APIs exposed by WordPress Playground
31 |
32 |
33 |
34 |
This site (Documentation) is where you will find all the information you need to start using Playground. To learn more about what this fantastic tool, read [Introduction to Playground: running WordPress in the browser](https://developer.wordpress.org/news/2024/04/05/introduction-to-playground-running-wordpress-in-the-browser/)
35 |
36 |
37 |
38 |
Quick start
39 |
40 |
41 |
42 |
43 |
[Start using WordPress Playground](../02-start-using/01-index.md) in 5 minutes (and check out the [demo site](https://playground.wordpress.net/))
44 |
45 |
46 |
47 |
Build your first app](../03-build-an-app/01-index.md) with WordPress Playground
48 |
49 |
50 |
51 |
Use Playground as a zero-setup [local development environment](../04-nodejs/01-index.md#start-a-zero-setup-dev-environment-via-vscode-extension)
52 |
53 |
54 |
55 |
Read about the [limitations](../12-limitations/01-index.md)
Read about [Playground APIs](../06-playground-apis/01-index.md) and basic concepts
70 |
71 |
72 |
73 |
Review [links and resources](../15-resources.md)
74 |
75 |
76 |
77 |
Choose the right API for your app <APIList />
78 |
79 |
80 |
81 |
Dive into the [architecture](../11-architecture/01-index.md) and learn how it all works
82 |
83 |
84 |
85 |
86 |
Get Involved
87 |
88 |
89 |
90 |
WordPress Playground is an open-source project and welcomes all contributors from code to design, and from documentation to triage. Don't worry, you don't need to know WebAssembly to contribute!
91 |
92 |
93 |
94 |
95 |
See the [Contributors Handbook](../13-contributing/01-index.md) for all the details on how you can contribute.
96 |
97 |
98 |
99 |
Join us in the `#meta-playground` channel in Slack (see the [WordPress Slack page](https://make.wordpress.org/chat/) for signup information)
100 |
101 |
102 |
103 |
104 |
As with all WordPress projects, we want to ensure a welcoming environment for everyone. With that in mind, all contributors are expected to follow our Code of Conduct.
105 |
106 |
107 |
108 |
License
109 |
110 |
111 |
112 |
WordPress Playground is free software, and is released under the terms of the GNU General Public License version 2 or (at your option) any later version. See LICENSE.md. for complete license.
WordPress Playground is an online platform that allows you to experiment and learn about WordPress without affecting your live website. It's a virtual sandbox where you can play around with different features, designs, and settings in a safe and controlled environment.
5 |
6 |
7 |
8 |
Here's how it works
9 |
10 |
11 |
12 |
When you first start using WordPress Playground, you'll be provided with a separate space where you can create and customise your own WordPress website. This space is completely isolated from your actual website.
13 |
14 |
15 |
16 |
Try themes and plugins on the fly
17 |
18 |
19 |
20 |
Within the WordPress Playground, you can explore various themes. You can choose from a wide range of themes and see how they look on your site. You can also modify the colors, fonts, layouts, and other visual elements to create a unique design. In addition to themes, you can experiment with plugins too. With WordPress Playground, you can install and test different plugins to see how they work and what they can do for your site. This allows you to explore and understand the capabilities of WordPress without worrying about breaking anything.
21 |
22 |
23 |
24 |
Create content on the go
25 |
26 |
27 |
28 |
Another great feature of WordPress Playground is the ability to create and edit content. You can write blog posts, create pages and add media like images and videos to your site. This helps you understand how to organize and structure your content effectively.
29 |
30 |
31 |
32 |
The content you create is limited to the Playground on your device and disappears once you leave it, so you are free to explore and play without risking breaking any actual site.
33 |
34 |
35 |
36 |
And, yes it's safe
37 |
38 |
39 |
40 |
Overall, WordPress Playground provides a risk-free environment for beginners to learn and get hands-on experience with WordPress. It helps you to gain confidence and knowledge before making changes to your live website.
41 |
42 |
43 |
44 |
What makes Playground different from running WordPress on a web server or local desktop app?
45 |
46 |
47 |
48 |
Web applications like WordPress have long-relied on server technologies to run logic and to store data.
49 |
50 |
51 |
52 |
Using those technologies has meant either running an web server connected to the internet or using those technologies in a desktop service or app (sometimes called a "WordPress local environment") that either leans on a virtual server with the technologies installed or the underlying technologies on the current device.
53 |
54 |
55 |
56 |
Playground is a novel way to stream server technologies -- and WordPress (and WP-CLI) -- as files that can then run in the browser.
57 |
58 |
59 |
60 |
Streamed, not served.
61 |
62 |
63 |
64 |
The WordPress you see when you open Playground in your browser is a WordPress that should function like any WordPress, with a few limitations and the important exception that it's not a permanent server with an internet address which will limit connections to some third-party services (automation, sharing, analysis, email, backups, etc.) in a persistient way.
65 |
66 |
67 |
68 |
The loading screen and progress bar you see on Playground includes both the streaming of those foundational technologies to your browser and configuration steps (examples) from WordPress Blueprints, so that a full server, WordPress software, Theme & Plugin solutions and configuration instructions can be streamed over-the-wire.
69 |
70 |
71 |
72 |
While many WordPress solutions may require internet connectivity to interact with social networks, live feeds and other internet services, those kind of connections could be limited in Playground. However, by enabling network connectivity in the Customize Playground settings modal (example URL w/ query parameter), you can mostly wire-up internet connectivity to the WordPress in Playground.
The build pipeline lives in a Dockerfile. In broad strokes, it:
5 |
6 |
7 |
8 |
9 |
Installs all the necessary linux packages (like build-essential)
10 |
11 |
12 |
13 |
Downloads PHP and the required libraries, e.g. sqlite3.
14 |
15 |
16 |
17 |
Applies a few patches.
18 |
19 |
20 |
21 |
Compiles everything using Emscripten, a drop-in replacement for the C compiler.
22 |
23 |
24 |
25 |
Compiles php_wasm.c – a convenient API for JavaScript.
26 |
27 |
28 |
29 |
Outputs a php.wasm file and one or more JavaScript loaders, depending on the configuration.
30 |
31 |
32 |
33 |
Transforms the Emscripten's default php.js output into an ESM module with additional features.
34 |
35 |
36 |
37 |
38 |
To find out more about each step, refer directly to the Dockerfile.
39 |
40 |
41 |
42 |
Building
43 |
44 |
45 |
46 |
To build all PHP versions, run npm run recompile:php:web (or php-wasm-node) in the repository root. You'll find the output files in packages/php-wasm/php-web/public. To build a specific version, run npm run recompile:php:web:kitchen-sink:8.0 or npm run recompile:php:web:light:8.0 – depending on the build pack.
47 |
48 |
49 |
50 |
The build produces two files: php.wasm and php.js.
51 |
52 |
53 |
54 |
PHP.wasm WebAssembly module
55 |
56 |
57 |
58 |
PHP extensions
59 |
60 |
61 |
62 |
PHP is built with several extensions listed in the Dockerfile.
63 |
64 |
65 |
66 |
Some extensions, like zip, can be turned on or off during the build. Others, like sqlite3, are hardcoded.
67 |
68 |
69 |
70 |
If you need to turn off one of the hardcoded extensions, feel free to open an issue in this repo. Better yet, this project needs contributors. You are more than welcome to open a PR and author the change you need.
71 |
72 |
73 |
74 |
C API exposed to JavaScript
75 |
76 |
77 |
78 |
The C API exposed to JavaScript lives in the php_wasm.c file.
79 |
80 |
81 |
82 |
Refer to the source code and the inline documentation in php_wasm.c to learn more.
83 |
84 |
85 |
86 |
Build configuration
87 |
88 |
89 |
90 |
The build is configurable via the Docker --build-arg feature. You can set them up through the build.js script, just run this command to get the usage message:
91 |
92 |
93 |
94 |
npm run recompile:php:web
95 |
96 |
97 |
98 |
PHP.js JavaScript module
99 |
100 |
101 |
102 |
The php.js file generated by the WebAssembly PHP build pipeline is not a vanilla Emscripten module. Instead, it's an ESM module that wraps the regular Emscripten output and adds some extra functionality.
The generated JavaScript module is not meant for direct use. Instead, it can be consumed through a NodePHP class in Node.js and a WebPHP class in the browser:
122 |
123 |
124 |
125 |
// In Node.js:
126 | const php = NodePHP.load('7.4');
127 |
128 | // On the web:
129 | const php = await WebPHP.load('8.0');
130 |
131 |
132 |
133 |
Both of these classes extend the BasePHP class exposed by the @php-wasm/universal package and implement the UniversalPHP interface that standardizes the API across all PHP environments.
134 |
135 |
136 |
137 |
Loading the PHP runtime
138 |
139 |
140 |
141 |
The load() method handles the entire PHP initialization pipeline. In particular, it:
142 |
143 |
144 |
145 |
146 |
Instantiates the Emscripten PHP module
147 |
148 |
149 |
150 |
Wires it together with the data dependencies and loads them
151 |
152 |
153 |
154 |
Ensures is all happens in a correct order
155 |
156 |
157 |
158 |
Waits until the entire loading sequence is finished
159 |
160 |
--------------------------------------------------------------------------------
/wp-content/plugins/wp-docs-plugin/playground-post-export-processor.php:
--------------------------------------------------------------------------------
1 | get_after_opener_tag_and_before_closer_tag_positions();
53 | if ( ! $positions ) {
54 | return null;
55 | }
56 |
57 | return substr( $this->html, $positions['after_opener_tag'], $positions['before_closer_tag'] - $positions['after_opener_tag'] );
58 | }
59 |
60 | public function remove_balanced_tag()
61 | {
62 | $positions = $this->get_after_opener_tag_and_before_closer_tag_positions();
63 | if ( ! $positions ) {
64 | return null;
65 | }
66 | $this->lexical_updates[] = new WP_HTML_Text_Replacement(
67 | $positions['before_opener_tag'],
68 | $positions['after_closer_tag'],
69 | ''
70 | );
71 |
72 | return true;
73 |
74 | }
75 |
76 | /**
77 | * Sets the content between two balanced tags.
78 | *
79 | * @since 6.5.0
80 | *
81 | * @access private
82 | *
83 | * @param string $new_content The string to replace the content between the matching tags.
84 | * @return bool Whether the content was successfully replaced.
85 | */
86 | public function set_content_between_balanced_tags( string $new_content ): bool {
87 | $positions = $this->get_after_opener_tag_and_before_closer_tag_positions( true );
88 | if ( ! $positions ) {
89 | return false;
90 | }
91 | list( $after_opener_tag, $before_closer_tag ) = $positions;
92 |
93 | $this->lexical_updates[] = new WP_HTML_Text_Replacement(
94 | $after_opener_tag,
95 | $before_closer_tag - $after_opener_tag,
96 | esc_html( $new_content )
97 | );
98 |
99 | return true;
100 | }
101 |
102 | /**
103 | * Gets the positions right after the opener tag and right before the closer
104 | * tag in a balanced tag.
105 | *
106 | * By default, it positions the cursor in the closer tag of the balanced tag.
107 | * If $rewind is true, it seeks back to the opener tag.
108 | *
109 | * @since 6.5.0
110 | *
111 | * @access private
112 | *
113 | * @param bool $rewind Optional. Whether to seek back to the opener tag after finding the positions. Defaults to false.
114 | * @return array|null Start and end byte position, or null when no balanced tag bookmarks.
115 | */
116 | private function get_after_opener_tag_and_before_closer_tag_positions( bool $rewind = false ) {
117 | // Flushes any changes.
118 | $this->get_updated_html();
119 |
120 | $bookmarks = $this->get_balanced_tag_bookmarks();
121 | if ( ! $bookmarks ) {
122 | return null;
123 | }
124 | list( $opener_tag, $closer_tag ) = $bookmarks;
125 |
126 | $positions = array(
127 | 'before_opener_tag' => $this->bookmarks[$opener_tag]->start,
128 | 'after_opener_tag' => $this->bookmarks[$opener_tag]->start + $this->bookmarks[$opener_tag]->length + 1,
129 | 'before_closer_tag' => $this->bookmarks[$closer_tag]->start,
130 | 'after_closer_tag' => $this->bookmarks[$closer_tag]->start + $this->bookmarks[$closer_tag]->length + 1,
131 | );
132 |
133 | if ( $rewind ) {
134 | $this->seek( $opener_tag );
135 | }
136 |
137 | $this->release_bookmark( $opener_tag );
138 | $this->release_bookmark( $closer_tag );
139 |
140 | return $positions;
141 | }
142 |
143 | /**
144 | * Returns a pair of bookmarks for the current opener tag and the matching
145 | * closer tag.
146 | *
147 | * It positions the cursor in the closer tag of the balanced tag, if it
148 | * exists.
149 | *
150 | * @since 6.5.0
151 | *
152 | * @return array|null A pair of bookmarks, or null if there's no matching closing tag.
153 | */
154 | private function get_balanced_tag_bookmarks() {
155 | static $i = 0;
156 | $opener_tag = 'opener_tag_of_balanced_tag_' . ++$i;
157 |
158 | $this->set_bookmark( $opener_tag );
159 | if ( ! $this->next_balanced_tag_closer_tag() ) {
160 | $this->release_bookmark( $opener_tag );
161 | return null;
162 | }
163 |
164 | $closer_tag = 'closer_tag_of_balanced_tag_' . ++$i;
165 | $this->set_bookmark( $closer_tag );
166 |
167 | return array( $opener_tag, $closer_tag );
168 | }
169 |
170 | /**
171 | * Finds the matching closing tag for an opening tag.
172 | *
173 | * When called while the processor is on an open tag, it traverses the HTML
174 | * until it finds the matching closer tag, respecting any in-between content,
175 | * including nested tags of the same name. Returns false when called on a
176 | * closer tag, a tag that doesn't have a closer tag (void), a tag that
177 | * doesn't visit the closer tag, or if no matching closing tag was found.
178 | *
179 | * @since 6.5.0
180 | *
181 | * @access private
182 | *
183 | * @return bool Whether a matching closing tag was found.
184 | */
185 | public function next_balanced_tag_closer_tag(): bool {
186 | $depth = 0;
187 | $tag_name = $this->get_tag();
188 |
189 | if ( ! $this->has_and_visits_its_closer_tag() ) {
190 | return false;
191 | }
192 |
193 | while ( $this->next_tag(
194 | array(
195 | 'tag_name' => $tag_name,
196 | 'tag_closers' => 'visit',
197 | )
198 | ) ) {
199 | if ( ! $this->is_tag_closer() ) {
200 | ++$depth;
201 | continue;
202 | }
203 |
204 | if ( 0 === $depth ) {
205 | return true;
206 | }
207 |
208 | --$depth;
209 | }
210 |
211 | return false;
212 | }
213 |
214 | /**
215 | * Checks whether the current tag has and will visit its matching closer tag.
216 | *
217 | * @since 6.5.0
218 | *
219 | * @access private
220 | *
221 | * @return bool Whether the current tag has a closer tag.
222 | */
223 | public function has_and_visits_its_closer_tag(): bool {
224 | $tag_name = $this->get_tag();
225 |
226 | return null !== $tag_name && (
227 | // @TODO: Backport the 6.5 method
228 | // ! WP_HTML_Tag_Processor::is_void( $tag_name ) &&
229 | ! in_array( $tag_name, self::TAGS_THAT_DONT_VISIT_CLOSER_TAG, true )
230 | );
231 | }
232 | }
233 | }
--------------------------------------------------------------------------------
/wp-content/html-pages/1_developer-apis/1_query-api.html:
--------------------------------------------------------------------------------
1 |
Query API
2 |
3 |
4 |
WordPress Playground exposes a simple API that you can use to configure the Playground in the browser.
5 |
6 |
7 |
8 |
It works by passing configuration options as query parameters to the Playground URL. For example, to install the pendant theme, you would use the following URL:
9 |
10 |
11 |
12 |
https://playground.wordpress.net/?theme=pendant
13 |
14 |
15 |
16 |
You can go ahead and try it out. The Playground will automatically install the theme and log you in as an admin. You may even embed this URL in your website using an <iframe> tag:
Loads the specified WordPress version. Supported values: The last three major WordPress versions. As of April 4, 2024, that's 6.3, 6.4, 6.5. You can also use these values: latest, nightly, beta
blueprint-url
The URL of the Blueprint that will be used to configure this Playground instance.
php-extension-bundle
Loads a bundle of PHP extensions. Supported bundles: kitchen-sink (for finfo, gd, mbstring, iconv, openssl, libxml, xml, dom, simplexml, xmlreader, xmlwriter), light (saves 6MB of downloads, loads none of the above extensions)
networking
yes or no
Enables or disables the networking support for Playground. Defaults to no
plugin
Installs the specified plugin. Use the plugin name from the plugins directory URL, e.g. for a URL like https://wordpress.org/plugins/wp-lazy-loading/, the plugin name would be wp-lazy-loading. You can pre-install multiple plugins by saying plugin=coblocks&plugin=wp-lazy-loading&…. Installing a plugin automatically logs the user in as an admin
theme
Installs the specified theme. Use the theme name from the themes directory URL, e.g. for a URL like https://wordpress.org/themes/disco/, the theme name would be disco. Installing a theme automatically logs the user in as an admin
url
/wp-admin/
Load the specified initial page displaying WordPress
mode
seamless, browser, or browser-full-screen
Displays WordPress on a full-page or wraps it in a browser UI
lazy
Defer loading the Playground assets until someone clicks on the "Run" button
login
yes
Logs the user in as an admin. Set to no to not log in.
multisite
no
Enables the WordPress multisite mode.
storage
Selects the storage for Playground: none gets erased on page refresh, browser is stored in the browser, and device is stored in the selected directory on a device. The last two protect the user from accidentally losing their work upon page refresh.
import-site
Imports site files and database from a zip file specified by URL.
import-wxr
Imports site content from a WXR file specified by URL. It uses the WordPress Importer, so the default admin user must be logged in.
29 |
30 |
31 |
32 |
For example, the following code embeds a Playground with a preinstalled Gutenberg plugin, and opens the post editor:
To import files from a URL, such as a site zip package, they must be served with Access-Control-Allow-Origin header set. For reference, see: Cross-Origin Resource Sharing (CORS).
45 |
46 |
47 |
48 |
:::
49 |
50 |
51 |
52 |
GitHub Export Options
53 |
54 |
55 |
56 |
The following additional query parameters may be used to pre-configure the GitHub export form:
57 |
58 |
59 |
60 |
61 |
gh-ensure-auth: If set to yes, Playground will display a modal to ensure the user is authenticated with GitHub before proceeding.
62 |
63 |
64 |
65 |
ghexport-repo-url: The URL of the GitHub repository to export to.
66 |
67 |
68 |
69 |
ghexport-pr-action: The action to take when exporting (create or update).
70 |
71 |
72 |
73 |
ghexport-playground-root: The root directory in the Playground to export from.
74 |
75 |
76 |
77 |
ghexport-repo-root: The root directory in the repository to export to.
78 |
79 |
80 |
81 |
ghexport-content-type: The content type of the export (plugin, theme, wp-content, custom-paths).
82 |
83 |
84 |
85 |
ghexport-plugin: Plugin path. When the content type is plugin, pre-select the plugin to export.
86 |
87 |
88 |
89 |
ghexport-theme: Theme directory name. When the content type is theme, pre-select the theme to export.
90 |
91 |
92 |
93 |
ghexport-path: A path relative to ghexport-playground-root. Can be provided multiple times. When the content type is custom-paths, it pre-populates the list of paths to export.
94 |
95 |
96 |
97 |
ghexport-commit-message: The commit message to use when exporting.
98 |
99 |
100 |
101 |
ghexport-allow-include-zip: Whether to offer an option to include a zip file in the GitHub export (yes, no). Optional. Defaults to yes.
You can then create pages, upload plugins, themes, import your own site, and do most things you would do on a regular WordPress.
29 |
30 |
31 |
32 |
It's that easy to start!
33 |
34 |
35 |
36 |
The entire site lives in your browser and is scraped when you close the tab. Want to start over? Just refresh the page!
37 |
38 |
39 |
40 |
41 |
WordPress Playground is private
42 | Everything you build stays in your browser and is not sent anywhere. Once you're finished, you can export your site as a zip file. Or just refresh the page and start over!
43 |
44 |
45 |
46 |
Try a block, a theme, or a plugin
47 |
48 |
49 |
50 |
You can upload any plugin or theme you want in /wp-admin/.
51 |
52 |
53 |
54 |
To save a few clicks, you can preinstall plugins or themes from the WordPress plugin directory by adding a plugin or theme parameter to the URL. For example, to install the coblocks plugin, you can use this URL:
This is called Query API and you can learn more about it here.
79 |
80 |
81 |
82 |
83 |
Plugin directory doesn't work in WordPress Playground
84 | Plugins must be installed manually because your WordPress site doesn't send any data to the internet. You won't be able to navigate the WordPress plugin directory inside /wp-admin/. The Query API method may seem to contradict that, but behind the scenes it uses the same plugin upload form as you would.
85 |
86 |
87 |
88 |
Save your site
89 |
90 |
91 |
92 |
To keep your WordPress Playground site for longer than a single browser session, you can export it as a zip file.
93 |
94 |
95 |
96 |
Use the "Export" button in the top bar:
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
The exported file contains the complete site you've built. You could host it on any server that supports PHP and SQLite. All WordPress core files, plugins, themes, and everything else you've added to your site are in there.
105 |
106 |
107 |
108 |
The SQLite database file is also included in the export, you'll find it wp-content/database/.ht.sqlite. Keep in mind that files starting with a dot are hidden by default on most operating systems so you might need to enable the "Show hidden files" option in your file manager.
109 |
110 |
111 |
112 |
Restore a saved site
113 |
114 |
115 |
116 |
You can restore the site you saved by using the import button in WordPress Playground:
138 | Compatibility testing with so many WordPres and PHP versions was always a pain. WordPress Playground makes this process effortless – use it to your advantage!
139 |
140 |
141 |
142 |
You can also use the wp and php query parameters to open Playground with the right versions already loaded:
143 |
144 |
145 |
146 |
147 |
https://playground.wordpress.net/?wp=6.5
148 |
149 |
150 |
151 |
https://playground.wordpress.net/?php=7.4
152 |
153 |
154 |
155 |
https://playground.wordpress.net/?php=8.2&wp=6.2
156 |
157 |
158 |
159 |
160 |
This is called Query API and you can learn more about it here.
161 |
162 |
163 |
164 |
165 |
Major versions only
166 | You can specify major versions like wp=6.2 or php=8.1 and expect the most recent release in that line. You cannot, however, request older minor versions so neither wp=6.1.2 nor php=7.4.9 will work.
167 |
168 |
169 |
170 |
Import a WXR file
171 |
172 |
173 |
174 |
You can import a WordPress export file by uploading a WXR file in /wp-admin/.
This is different from the import feature described above. The import feature exports the entire site, including the database. This import feature imports a WXR file into an existing site.
183 |
184 |
185 |
186 |
Build apps with WordPress Playground
187 |
188 |
189 |
190 |
WordPress Playground is programmable which means you can build WordPress apps, setup plugin demos, and even use it as a zero-setup local development environment.
191 |
192 |
193 |
194 |
To learn more about developing with WordPress Playground, check out the development quick start section.
195 |
--------------------------------------------------------------------------------
/wp-content/plugins/wp-docs-plugin/plugin.php:
--------------------------------------------------------------------------------
1 | get_var("SHOW TABLES LIKE 'wp_options'") != 'wp_options') {
26 | return;
27 | }
28 | initialize_docs_plugin();
29 | });
30 |
31 | add_filter('http_request_args', function ($args, $url) {
32 | $args['reject_unsafe_urls'] = true;
33 | return $args;
34 | }, 10, 2);
35 |
36 | add_filter('allowed_redirect_hosts', function ($deprecated = '') {
37 | return array ();
38 | });
39 |
40 | function initialize_docs_plugin() {
41 | if(get_option('docs_populated')) {
42 | // Prevent collisions between the initial create_db_pages_from_html_files call
43 | // process and the save_post_page hook.
44 | return;
45 | }
46 |
47 | if(!file_exists(HTML_PAGES_PATH)) {
48 | return;
49 | }
50 |
51 | update_option('permalink_structure', '/%postname%/');
52 | flush_rewrite_rules();
53 | // Activating here because the activateTheme Blueprint step doesn't work
54 | // in wp-now :(
55 | switch_theme('playground-docs');
56 | // Activate the gutenberg plugin and the create-block-theme plugin
57 | // for the same reasons.
58 | require_once ABSPATH . 'wp-admin/includes/plugin.php';
59 | activate_plugin('gutenberg/gutenberg.php');
60 | activate_plugin('create-block-theme/create-block-theme.php');
61 | pages_reinitialize_content();
62 | }
63 |
64 | add_action('admin_menu', function () {
65 | // Remove distracting options from the admin menu
66 | remove_menu_page('edit.php');
67 | remove_menu_page('edit-comments.php');
68 | remove_menu_page('users.php');
69 |
70 | // Add a submenu under "Docs pages" menu
71 | add_submenu_page(
72 | 'edit.php?post_type=page',
73 | 'Download ZIP',
74 | 'Download ZIP',
75 | 'manage_options',
76 | 'download_docs',
77 | function () { }
78 | );
79 | // Add a submenu under "Docs pages" menu
80 | add_submenu_page(
81 | 'edit.php?post_type=page',
82 | 'Reload doc pages from disk',
83 | 'Reload doc pages from disk',
84 | 'manage_options',
85 | 'recreate_db_pages_from_disk',
86 | function () { }
87 | );
88 | });
89 |
90 | add_action('admin_init', function () {
91 | if (isset($_GET['page']) && $_GET['page'] === 'download_docs') {
92 | return download_docs_callback();
93 | }
94 | if (isset($_GET['page']) && $_GET['page'] === 'recreate_db_pages_from_disk') {
95 | pages_reinitialize_content();
96 |
97 | // Display admin notice
98 | add_action('admin_notices', function () {
99 | echo '
Doc pages were recreated successfully.
';
100 | });
101 | }
102 | });
103 |
104 | function pages_reinitialize_content() {
105 | update_option('docs_populated', false);
106 |
107 | delete_db_pages(HTML_PAGES_PATH);
108 | create_db_pages_from_html_files(HTML_PAGES_PATH);
109 | delete_db_attachments();
110 | create_db_media_files_from_uploads();
111 |
112 | update_option('docs_populated', true);
113 | }
114 |
115 | /**
116 | * Doc pages functions
117 | */
118 |
119 | function download_docs_callback() {
120 | // Create a zip file of the HTML_PAGES_PATH directory
121 | $zipFile = __DIR__ . '/docs.zip';
122 | $zip = new ZipArchive();
123 | if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) {
124 | $files = new RecursiveIteratorIterator(
125 | new RecursiveDirectoryIterator(HTML_PAGES_PATH),
126 | RecursiveIteratorIterator::LEAVES_ONLY
127 | );
128 |
129 | foreach ($files as $name => $file) {
130 | if (!$file->isDir()) {
131 | $filePath = $file->getRealPath();
132 | $relativePath = substr($filePath, strlen(HTML_PAGES_PATH) + 1);
133 | $zip->addFile($filePath, $relativePath);
134 | }
135 | }
136 |
137 | $zip->close();
138 |
139 | // Download the zip file
140 | header('Content-Type: application/zip');
141 | header('Content-Disposition: attachment; filename="docs.zip"');
142 | header('Content-Length: ' . filesize($zipFile));
143 | readfile($zipFile);
144 |
145 | // Delete the zip file
146 | unlink($zipFile);
147 | } else {
148 | echo 'Failed to create zip file';
149 | }
150 |
151 | exit;
152 | }
153 |
154 | /**
155 | * Recreate the entire file structure when any post is saved.
156 | *
157 | * Why recreate?
158 | *
159 | * It's easier to recreate the entire file structure than to keep track of
160 | * which files have been added, deleted, renamed and moved under
161 | * another parent, or changed via a direct SQL query.
162 | */
163 | add_action('save_post_page', function ($post_id) {
164 | // Prevent collisions between the initial create_db_pages_from_html_files call
165 | // process and the save_post_page hook.
166 | if (!get_option('docs_populated')) {
167 | return;
168 | }
169 |
170 | docs_plugin_deltree(HTML_PAGES_PATH);
171 | mkdir(HTML_PAGES_PATH);
172 | save_db_pages_as_html(HTML_PAGES_PATH);
173 | });
174 |
175 |
176 | function create_db_pages_from_html_files($dir, $parent_id = 0) {
177 | $indexFilePath = $dir . '/index.html';
178 | if(file_exists($indexFilePath)) {
179 | $parent_id = create_db_page_from_html_file(
180 | new SplFileInfo($indexFilePath),
181 | $parent_id,
182 | get_order_from_filename(basename($dir))
183 | );
184 | }
185 |
186 | $files = scandir($dir);
187 | foreach ($files as $file) {
188 | if ($file === '.' || $file === '..' || $file === 'index.html') {
189 | continue;
190 | }
191 |
192 | $filePath = $dir . '/' . $file;
193 | if (is_dir($filePath)) {
194 | create_db_pages_from_html_files($filePath, $parent_id);
195 | } else if (pathinfo($file, PATHINFO_EXTENSION) === 'html') {
196 | create_db_page_from_html_file(
197 | new SplFileInfo($filePath),
198 | $parent_id,
199 | get_order_from_filename(basename($filePath))
200 | );
201 | }
202 | }
203 | }
204 |
205 | function get_order_from_filename($filename) {
206 | if(preg_match('/^(\d+)_/', $filename, $matches)) {
207 | return $matches[1];
208 | }
209 | return 0;
210 | }
211 |
212 | function create_db_page_from_html_file(SplFileInfo $file, $parent_id = 0, $order = 0) {
213 | $content = file_get_contents($file->getRealPath());
214 | $p = new Playground_Post_Export_Processor($content);
215 | $p->next_tag();
216 | if($p->get_tag() === 'H1') {
217 | $p->set_bookmark('start');
218 | $title = $p->get_content_between_balanced_template_tags();
219 | $p->seek('start');
220 | $p->remove_balanced_tag();
221 | // Removing the tag doesn't affect the whitespace that follows, so
222 | // we need to trim the content or else we'll start accumulating leading
223 | // newlines.
224 | try {
225 | $content = trim($p->get_updated_html());
226 | } catch(ValueError $e) {
227 | $content = '';
228 | }
229 | // Replace placeholder site URLs with the URL of the current site.
230 | // @TODO: This is very naive, let's actually parse the block
231 | // markup and the HTML markup and make these replacements
232 | // in the JSON and HTML attributes structures, not just in
233 | // their textual representation.
234 | $content = str_replace(
235 | DOCS_INTERNAL_SITE_URL,
236 | get_site_url(),
237 | $content
238 | );
239 | } else {
240 | $title = $file->getBasename('.html');
241 | }
242 |
243 | // Insert the content as a WordPress page
244 | $post_data = array(
245 | 'post_title' => $title,
246 | 'post_content' => $content,
247 | 'post_status' => 'publish',
248 | 'post_author' => get_current_user_id(),
249 | 'post_type' => 'page',
250 | 'post_parent' => $parent_id,
251 | 'menu_order' => $order
252 | );
253 |
254 | $page_id = wp_insert_post($post_data);
255 | if("0" == get_option('page_on_front')) {
256 | update_option('page_on_front', $page_id);
257 | }
258 | return $page_id;
259 | }
260 |
261 | function delete_db_pages() {
262 | $args = array(
263 | 'post_type' => 'page',
264 | 'posts_per_page' => -1,
265 | 'post_status' => 'any',
266 | );
267 | $pages = new WP_Query($args);
268 |
269 | if ($pages->have_posts()) {
270 | while ($pages->have_posts()) {
271 | $pages->the_post();
272 | wp_delete_post(get_the_ID(), true);
273 | }
274 | }
275 | wp_reset_postdata();
276 | }
277 |
278 | function save_db_pages_as_html($path, $parent_id = 0) {
279 | if (!file_exists($path)) {
280 | mkdir($path, 0777, true);
281 | }
282 |
283 | $args = array(
284 | 'post_type' => 'page',
285 | 'posts_per_page' => -1,
286 | 'post_parent' => $parent_id,
287 | 'post_status' => 'publish',
288 | );
289 | $pages = new WP_Query($args);
290 |
291 | if ($pages->have_posts()) {
292 | while ($pages->have_posts()) {
293 | $pages->the_post();
294 | $page_id = get_the_ID();
295 | $page = get_post($page_id);
296 | $title = sanitize_title(get_the_title());
297 |
298 | $content = '
' . esc_html(get_the_title()) . "
\n\n" . get_the_content();
299 | // Replace current site URL with a placeholder URL for the export.
300 | // @TODO: This is very naive, let's actually parse the block
301 | // markup and the HTML markup and make these replacements
302 | // in the JSON and HTML attributes structures, not just in
303 | // their textual representation.
304 | $content = str_replace(
305 | get_site_url(),
306 | DOCS_INTERNAL_SITE_URL,
307 | $content
308 | );
309 | $child_pages = get_pages(array('child_of' => $page_id, 'post_type' => 'page'));
310 |
311 | if (!file_exists($path)) {
312 | mkdir($path, 0777, true);
313 | }
314 |
315 | if (!empty($child_pages)) {
316 | $new_parent = $path . '/' . $page->menu_order . '_' . $title;
317 | if (!file_exists($new_parent)) {
318 | mkdir($new_parent, 0777, true);
319 | }
320 | file_put_contents($new_parent . '/index.html', $content);
321 | save_db_pages_as_html($new_parent, $page_id);
322 | } else {
323 | file_put_contents($path . '/' . $page->menu_order . '_' . $title . '.html', $content);
324 | }
325 | }
326 | }
327 | wp_reset_postdata();
328 | }
329 |
330 | function docs_plugin_deltree($path) {
331 | if (!file_exists($path)) {
332 | return;
333 | }
334 |
335 | $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path), RecursiveIteratorIterator::CHILD_FIRST);
336 | foreach ($iterator as $file) {
337 | /** @var SplFileInfo $file */
338 | if ($file->isDir()) {
339 | rmdir($file->getRealPath());
340 | } else if($file->isFile()) {
341 | unlink($file->getRealPath());
342 | }
343 | }
344 |
345 | rmdir($path);
346 | }
347 |
348 | /**
349 | * Media attachments
350 | */
351 |
352 | function delete_db_attachments() {
353 | $args = array(
354 | 'post_type' => 'attachment',
355 | 'posts_per_page' => -1,
356 | 'post_status' => 'any',
357 | );
358 | $attachments = new WP_Query($args);
359 |
360 | add_filter('wp_delete_file', 'keep_media_file');
361 | if ($attachments->have_posts()) {
362 | while ($attachments->have_posts()) {
363 | $attachments->the_post();
364 | wp_delete_post(get_the_ID(), true);
365 | }
366 | }
367 | remove_filter('wp_delete_file', 'keep_media_file');
368 | wp_reset_postdata();
369 | }
370 |
371 | /**
372 | * Set this as a wp_delete_file filter to prevent
373 | * media files on the fisk from being deleted when
374 | * their corresponding database records are deleted.
375 | */
376 | function keep_media_file($file) {
377 | // This function does nothing, it's just a dummy function.
378 | return '';
379 | }
380 |
381 | function create_db_media_files_from_uploads() {
382 | $uploads = wp_upload_dir();
383 | $uploadsDir = $uploads['basedir'];
384 | $uploadsUrl = $uploads['baseurl'];
385 |
386 | $mediaFiles = new RecursiveIteratorIterator(
387 | new RecursiveDirectoryIterator($uploadsDir),
388 | RecursiveIteratorIterator::LEAVES_ONLY
389 | );
390 |
391 | foreach ($mediaFiles as $name => $file) {
392 | /** @var SplFileInfo $file */
393 | $filename = $file->getFilename();
394 | if($filename === '.gitkeep') {
395 | continue;
396 | }
397 | if (!$file->isDir()) {
398 | $filePath = $file->getRealPath();
399 | $relativePath = substr($filePath, strlen($uploadsDir) + 1);
400 | $attachment = array(
401 | 'guid' => $uploadsUrl . '/' . $relativePath,
402 | 'post_mime_type' => naive_mime_content_type($filePath),
403 | 'post_title' => pathinfo($filePath, PATHINFO_FILENAME),
404 | 'post_content' => '',
405 | 'post_status' => 'inherit',
406 | );
407 | if(preg_match('/^(\d+)_/', $filename, $matches)) {
408 | $attachmentId = $matches[1];
409 | $attachment['import_id'] = $attachmentId;
410 | }
411 | $attachmentId = wp_insert_attachment($attachment, $filePath);
412 | if (!is_wp_error($attachmentId)) {
413 | require_once ABSPATH . 'wp-admin/includes/image.php';
414 | $attachmentData = wp_generate_attachment_metadata($attachmentId, $filePath);
415 | wp_update_attachment_metadata($attachmentId, $attachmentData);
416 | }
417 | }
418 | }
419 | }
420 |
421 | // Don't generate thumbnails for images for now
422 | // so that the restore function has an easier job.
423 | // @TODO: Implement thumbnails export/import
424 | add_filter( 'intermediate_image_sizes_advanced', 'disable_image_sizes' );
425 |
426 | function disable_image_sizes ($sizes){
427 | unset( $sizes['thumbnail'] ); // Disable Thumbnail (150 x 150 hard cropped)
428 | unset( $sizes['medium'] ); // Disable Medium resolution (300 x 300 max height 300px)
429 | unset( $sizes['medium_large'] ); // Disable Medium Large (added in WP 4.4) resolution (768 x 0 infinite height)
430 | unset( $sizes['large'] ); // Disable Large resolution (1024 x 1024 max height 1024px)
431 |
432 | return $sizes;
433 | }
434 |
435 | /**
436 | * Workaround in wp-now where the finfo PHP extension
437 | * is not installed yet. Let's replace this with
438 | * mime_content_type once a new version is released.
439 | *
440 | * @param mixed $path
441 | * @return string
442 | */
443 | function naive_mime_content_type($path) {
444 | $extension = pathinfo($path, PATHINFO_EXTENSION);
445 | switch ($extension) {
446 | case 'jpg':
447 | case 'jpeg':
448 | return 'image/jpeg';
449 | case 'png':
450 | return 'image/png';
451 | case 'gif':
452 | return 'image/gif';
453 | case 'pdf':
454 | return 'application/pdf';
455 | default:
456 | return 'application/octet-stream';
457 | }
458 | }
459 |
460 | /**
461 | * The image block stores the attachment ID so we need
462 | * to preserve it in the export. Let's prepend it to the
463 | * filename so that we can restore it later.
464 | *
465 | * @param mixed $file
466 | * @return mixed
467 | */
468 | function rename_uploaded_file($attachment_id) {
469 | // Do not rename the attachments when importing.
470 | if (!get_option('docs_populated')) {
471 | return;
472 | }
473 | $file = get_attached_file($attachment_id);
474 | $path = pathinfo($file);
475 |
476 | // new filename structure here
477 | $newfilename = $attachment_id . "_" . $path['filename'];
478 | $newfile = $path['dirname']."/".$newfilename.".".$path['extension'];
479 |
480 | rename($file, $newfile);
481 | update_attached_file($attachment_id, $newfile);
482 | }
483 | add_action('add_attachment', 'rename_uploaded_file');
484 |
--------------------------------------------------------------------------------