Runnable docs. Click Run on any '
+ 'snippet to execute it on PHP 8.3 in your browser via WordPress Playground. '
+ 'Click into the code to edit it before running. The toolkit ships as a '
+ 'self-contained bundle with the page; nothing extra is downloaded.
\n'
+ )
+ for heading, body_html, snippet in sections:
+ anchor = re.sub(r'[^\w\s-]', '', heading.lower()).strip().replace(' ', '-')
+ out.append(f'\t\t
\n')
+ out.append(PAGE_FOOT)
+ return ''.join(out)
+
+
+# ---------------------------------------------------------------------------
+# Component catalog
+# ---------------------------------------------------------------------------
+
+LOAD = "require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';\n\n"
+
+
+def php(snippet):
+ return 'Working with HTML in PHP usually means choosing between libxml2 (heavyweight, parses HTML loosely), regex (broken on the first edge case), or DOMDocument (full document mode only). The toolkit\'s HTML component gives you the same API WordPress core uses, runs anywhere, and treats HTML5 the way browsers do.
'
+ '
You get two layers: WP_HTML_Tag_Processor for fast linear scanning and attribute rewriting, and WP_HTML_Processor for full HTML5 tree-construction semantics including implicit closers, foster parenting, and the active formatting elements algorithm.
',
+ None),
+ ('Lazy-load every image',
+ '
The most common need: rewrite tag attributes without reserializing the document. The tag processor handles each <img> in a single linear pass.
Need to read attribute values or text content as their decoded form? WP_HTML_Decoder::decode_attribute() handles entity references the way the spec demands — including the long tail of HTML5 named entities and the special rules for unterminated references in attributes.
The full processor lets you replace, insert, or remove entire subtrees by addressing a bookmark. Useful for scrubbing posts before display.
',
+ ('strip-scripts.php', php('''$html = <<
+\t
Body copy.
+\t
+\t
More copy.
+
+HTML;
+
+$tags = new WP_HTML_Tag_Processor( $html );
+while ( $tags->next_tag( 'script' ) ) {
+\t// Drop the script element entirely, including its content.
+\t$tags->remove_node();
+}
+echo $tags->get_updated_html();'''))),
+ ]))
+
+# ---------- Zip ----------
+COMPONENTS.append(('zip', 'Zip',
+ 'Read and write ZIP archives in pure PHP. No libzip, no ZipArchive. Streams entries incrementally so it works on multi-gigabyte archives without exhausting memory.',
+ 'wp-php-toolkit/zip',
+ [
+ ('Why this exists',
+ '
PHP\'s built-in ZIP support requires the libzip-backed ZipArchive extension, which isn\'t available everywhere — sandboxed shared hosts, WebAssembly runtimes, alpine images without the extension. The toolkit\'s Zip component reads and writes Stored and Deflate-compressed archives entirely in PHP and exposes a streaming API so you never have to load an archive into memory.
',
+ None),
+ ('Create an archive',
+ '
Encoder writes one entry at a time. The sink is any WriteStream — here a temp file. For huge archives, a FileWriteStream on the final destination keeps memory flat.
Every stream has the same shape: pull($n) asks the source for up to $n bytes (returning how many landed in the buffer), peek($n) looks without advancing, consume($n) reads and advances. Pull/consume separation lets parsers backtrack without ever copying out of the source buffer.
',
+ None),
+ ('Read a file in chunks',
+ '
The classic streaming-read loop: pull until you get bytes, consume them, repeat. Memory usage is bounded by the buffer size.
MemoryPipe is a bidirectional buffer — useful for tests, for wrapping a string in the stream interface, and for piping output of one component into another in-process.
Wrap any read stream with a transformer to compute checksums, count bytes, or compress as data flows through. The wrapped stream still satisfies ByteReadStream, so it composes.
',
+ ('count-lines.php', php('''use WordPress\\ByteStream\\MemoryPipe;
+use WordPress\\ByteStream\\ReadStream\\TransformedReadStream;
+
+$source = new MemoryPipe( "alpha\\nbeta\\ngamma\\ndelta\\n" );
+$source->close_writing();
+
+$line_count = 0;
+$counter = new TransformedReadStream(
+\t$source,
+\tfunction ( $bytes ) use ( &$line_count ) {
+\t\t$line_count += substr_count( $bytes, "\\n" );
+\t\treturn $bytes;
+\t}
+);
+
+while ( ! $counter->reached_end_of_data() ) {
+\t$n = $counter->pull( 1024 );
+\tif ( $n <= 0 ) break;
+\t$counter->consume( $n );
+}
+echo "{$line_count} lines.\\n";'''))),
+ ]))
+
+# ---------- Filesystem ----------
+COMPONENTS.append(('filesystem', 'Filesystem',
+ 'A unified filesystem abstraction across local disk, in-memory trees, SQLite, and ZIP archives. Forward-slash paths everywhere, even on Windows.',
+ 'wp-php-toolkit/filesystem',
+ [
+ ('Pick a backend',
+ '
Every backend implements the same Filesystem interface. Tests use InMemoryFilesystem, production uses LocalFilesystem, and code that reads ZIPs uses ZipFilesystem from the Zip component — same calls everywhere.
',
+ None),
+ ('In-memory tree',
+ '
The fastest backend. Stores everything in PHP arrays; ideal for tests and ephemeral processing.
Inner blocks recurse with the same shape, so a depth-first walk is a small recursive function.
',
+ ('walk-blocks.php', php('''$document = "\\n
\\n\\n
Title
\\n\\n\\n
Body.
\\n\\n
\\n";
+
+$blocks = ( new WP_Block_Parser() )->parse( $document );
+$walk = function ( array $blocks, int $depth = 0 ) use ( &$walk ) {
+\tforeach ( $blocks as $block ) {
+\t\tif ( ! $block['blockName'] ) continue;
+\t\techo str_repeat( ' ', $depth ) . $block['blockName'] . "\\n";
+\t\tif ( ! empty( $block['innerBlocks'] ) ) $walk( $block['innerBlocks'], $depth + 1 );
+\t}
+};
+$walk( $blocks );'''))),
+ ]))
+
+# ---------- Markdown ----------
+COMPONENTS.append(('markdown', 'Markdown',
+ 'Bidirectional converter between Markdown and WordPress block markup. Round-trips faithfully so you can keep Markdown files and a WP database in sync.',
+ 'wp-php-toolkit/markdown',
+ [
+ ('Markdown to blocks',
+ '
Pass a Markdown string to MarkdownConsumer and call consume(). The result exposes block markup and any YAML frontmatter as metadata.
',
+ ('md-to-blocks.php', php('''use WordPress\\Markdown\\MarkdownConsumer;
+
+$markdown = "# Hello World\\n\\nThis is a paragraph with **bold** text.";
+
+$consumer = new MarkdownConsumer( $markdown );
+$result = $consumer->consume();
+echo $result->get_block_markup();'''))),
+ ('Blocks back to Markdown',
+ '
MarkdownProducer walks block markup and emits matching Markdown. Round-tripping a document should produce equivalent output (modulo whitespace normalization).
Polyfills are loaded automatically through Composer\'s autoload.files. They define functions only when missing, so they\'re safe to use alongside PHP 8.
The Diff object is a flat list of equal/insert/delete operations.
',
+ ('diff.php', php('''use WordPress\\Merge\\Diff\\Diff;
+use WordPress\\Merge\\Diff\\LineDiffer;
+
+$diff = ( new LineDiffer() )->diff(
+\t"The quick brown fox\\njumps over the lazy dog.\\n",
+\t"The quick brown fox\\njumps over the lazy cat.\\nA new line.\\n"
+);
+
+foreach ( $diff->get_changes() as $change ) {
+\t$op = array( Diff::DIFF_EQUAL => '=', Diff::DIFF_DELETE => '-', Diff::DIFF_INSERT => '+' )[ $change[0] ];
+\techo $op . ' ' . trim( $change[1] ) . "\\n";
+}'''))),
+ ]))
+
+# ---------- HttpClient ----------
+COMPONENTS.append(('httpclient', 'HttpClient',
+ 'Async HTTP client without curl required. Uses sockets when curl is missing, supports concurrent requests and streaming responses.',
+ 'wp-php-toolkit/http-client',
+ [
+ ('Note',
+ '
Network access in the demo runtime. Snippets execute inside a sandboxed Playground; outbound HTTP requires the CORS proxy. The example below shows the API, but the request itself may not complete in this environment.
',
+ None),
+ ('GET a URL',
+ '
Build a Request, hand it to Client::fetch(), await the response, read the body.
',
+ ('get.php', php('''use WordPress\\HttpClient\\Client;
+use WordPress\\HttpClient\\Request;
+
+$client = new Client();
+$stream = $client->fetch( new Request( 'https://example.com/' ) );
+
+$response = $stream->await_response();
+echo "status: " . $response->status_code . "\\n";
+echo "first 80 bytes: " . substr( $stream->consume_all(), 0, 80 ) . "\\n";'''))),
+ ]))
+
+# ---------- HttpServer ----------
+COMPONENTS.append(('httpserver', 'HttpServer',
+ 'A minimal blocking TCP HTTP server in pure PHP. For CLI tools and tests, not for production traffic.',
+ 'wp-php-toolkit/http-server',
+ [
+ ('API shape',
+ '
Bind a port, set a handler that takes IncomingRequest and writes to a ResponseWriteStream, call serve(). The handler runs synchronously per request.
'
+ '
Won\'t bind in this runtime. The Playground sandbox doesn\'t allow listening on TCP ports, so the snippet below is illustrative — copy it to your machine to run it.
',
+ ('server.php', '''set_handler( function ( IncomingRequest $request, ResponseWriteStream $response ) {
+\t$response->send_http_code( 200 );
+\t$response->send_header( 'Content-Type', 'text/plain' );
+\t$response->append_bytes( 'Hello, world!' );
+} );
+
+echo "Listening on http://127.0.0.1:8080\\n";
+$server->serve();''')),
+ ]))
+
+# ---------- CORSProxy ----------
+COMPONENTS.append(('corsproxy', 'CORSProxy',
+ 'A small PHP CORS proxy intended for browser-side code that needs to reach servers without CORS headers.',
+ 'wp-php-toolkit/corsproxy',
+ [
+ ('Deployment shape',
+ '
Drop cors-proxy.php into a webroot. Clients append the upstream URL to the proxy path. The proxy streams the response back with CORS headers and blocks private IP ranges.
'
+ '
Operational, not runtime. The proxy is a deployable PHP file rather than a library you call from code, so there\'s no useful in-browser snippet. See the README for deployment details.
',
+ None),
+ ]))
+
+# ---------- Blueprints ----------
+COMPONENTS.append(('blueprints', 'Blueprints',
+ 'Declarative WordPress site provisioning. Write a JSON description of plugins, options, and content; let the runner execute it.',
+ 'wp-php-toolkit/blueprints',
+ [
+ ('Two execution modes',
+ '
EXECUTION_MODE_CREATE_NEW_SITE downloads WordPress and installs it. EXECUTION_MODE_APPLY_TO_EXISTING_SITE applies steps to an installed site. The snippet below shows the second; the first needs filesystem write access this runtime doesn\'t have.
',
+ None),
+ ('Apply a step',
+ '
You can run a single Blueprint step against the currently-running WordPress install — exactly what the <php-snippet> blueprint mechanism does under the hood.
Eighteen standalone pure-PHP libraries for WordPress and general PHP, with no extension or Composer dependencies. Every example on this site runs in your browser via WordPress Playground — click Run, edit the code, run again.
+
+\t
Components
+\t
+{cards_html}
+\t
+
+\t
How these examples work
+\t
Each page embeds <php-snippet> elements from WordPress Playground. The first Run click on a page boots a single shared PHP+WordPress runtime in your browser via WebAssembly and unzips the toolkit into it. Subsequent snippets reuse the same runtime, so only the first run pays the boot cost.
+\t
The toolkit bundle (docs/assets/php-toolkit.zip, ≈1.8 MB) ships with the docs, so no third-party CDN is involved.
+
+
+
+
+'''
+
+
+def main():
+ # Index
+ with open(os.path.join(DOCS, 'index.html'), 'w') as f:
+ f.write(render_index())
+
+ # Component pages
+ for slug, title, lede, install, sections in COMPONENTS:
+ out_dir = os.path.join(DOCS, slug)
+ os.makedirs(out_dir, exist_ok=True)
+ with open(os.path.join(out_dir, 'index.html'), 'w') as f:
+ f.write(render_component(slug, title, lede, install, sections))
+ print(f' wrote {slug}/index.html')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/bin/serve-docs.py b/bin/serve-docs.py
new file mode 100755
index 000000000..da64cc89c
--- /dev/null
+++ b/bin/serve-docs.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+"""
+Local dev server for docs/. Adds CORS headers so the WordPress Playground
+iframe can fetch docs/assets/php-toolkit.zip across origins.
+
+GitHub Pages serves Access-Control-Allow-Origin: * by default, so this
+server is only needed for `python3 -m http.server`-equivalent local previews.
+
+Usage:
+ python3 bin/serve-docs.py [port]
+"""
+
+import http.server
+import os
+import sys
+
+PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8787
+DOCS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'docs')
+
+
+class CorsHandler(http.server.SimpleHTTPRequestHandler):
+ def end_headers(self):
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.send_header('Access-Control-Allow-Headers', '*')
+ super().end_headers()
+
+
+os.chdir(DOCS)
+print(f'Serving {DOCS} on http://localhost:{PORT}/')
+http.server.ThreadingHTTPServer(('', PORT), CorsHandler).serve_forever()
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 000000000..ac0f34923
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,58 @@
+# Runnable docs site
+
+Static HTML at `docs/` deployed to GitHub Pages by `.github/workflows/docs.yml`.
+
+## How a page works
+
+Every component page is generated by `bin/build-docs.py` from a single content
+catalog. The page imports two scripts:
+
+- `https://playground.wordpress.net/php-code-snippet.js` — the upstream
+ `` web component (see WordPress/wordpress-playground#3528 and
+ #3536). It handles the Run button, the syntax-highlighted code rendering, and
+ shared-runtime reuse across all snippets on a page.
+- `assets/page.js` — local enhancement script that fills the shared blueprint
+ with an absolute URL to `assets/php-toolkit.zip`, builds the sticky
+ table-of-contents from `
` headings, and patches each snippet's shadow DOM
+ to make the rendered code editable.
+
+The blueprint sits in a `
+
+
+
+
+
+ PHP Toolkit
+
+
+
+
+
+
+
BlockParser
+
WordPress core's block parser as a standalone library. Same parser, no WP dependency.
+ composer require wp-php-toolkit/blockparser
+
Runnable docs. Click Run on any snippet to execute it on PHP 8.3 in your browser via WordPress Playground. Click into the code to edit it before running. The toolkit ships as a self-contained bundle with the page; nothing extra is downloaded.
+
Parse block markup
+
Pass the parser any HTML containing block delimiters and get back a structured array — blockName, attrs, innerBlocks, innerHTML, innerContent.
+
+
+
+
Self-closing blocks
+
Void blocks like core/spacer end with /-->. They have no inner HTML, just attributes.
+
+
+
+
Walk nested blocks
+
Inner blocks recurse with the same shape, so a depth-first walk is a small recursive function.