From 2dd73e67ca60d337a0207e41751e18bb87fbb4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 28 Apr 2026 20:16:51 +0200 Subject: [PATCH 1/7] Add runnable docs site for every component Eighteen pure-PHP libraries deserve docs you can poke at, not just read. Every page on this site embeds elements that boot WordPress Playground in your browser, unzip the toolkit into it, and run examples against the real library. Click a snippet to edit it; click Run again. The site lives at docs/ and is generated by bin/build-docs.py from a single content catalog. Snippets share one runtime per page via the blueprint mechanism shipped in WordPress/wordpress-playground#3536, so the WASM + WordPress download only happens on the first Run click. Pages have a sticky table-of-contents built from the h2 headings, an editable code area (contenteditable on the rendered as a stopgap until ships an editable attribute upstream), and GitHub-flavored light/dark styling. GitHub Actions deploys docs/ to Pages on every push to trunk; the workflow rebuilds the toolkit bundle and regenerates pages from trunk's components. --- .github/workflows/docs.yml | 60 +++ bin/build-docs-bundle.sh | 20 + bin/build-docs.py | 856 +++++++++++++++++++++++++++++++ bin/serve-docs.py | 30 ++ docs/README.md | 58 +++ docs/assets/page.js | 184 +++++++ docs/assets/php-toolkit.zip | Bin 0 -> 1853465 bytes docs/assets/style.css | 306 +++++++++++ docs/blockparser/index.html | 86 ++++ docs/blueprints/index.html | 57 ++ docs/bytestream/index.html | 113 ++++ docs/cli/index.html | 60 +++ docs/coding-standards/index.html | 40 ++ docs/corsproxy/index.html | 38 ++ docs/dataliberation/index.html | 68 +++ docs/encoding/index.html | 77 +++ docs/filesystem/index.html | 115 +++++ docs/git/index.html | 78 +++ docs/html/index.html | 138 +++++ docs/httpclient/index.html | 56 ++ docs/httpserver/index.html | 60 +++ docs/index.html | 51 ++ docs/markdown/index.html | 94 ++++ docs/merge/index.html | 79 +++ docs/polyfill/index.html | 63 +++ docs/xml/index.html | 77 +++ docs/zip/index.html | 149 ++++++ 27 files changed, 3013 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100755 bin/build-docs-bundle.sh create mode 100755 bin/build-docs.py create mode 100755 bin/serve-docs.py create mode 100644 docs/README.md create mode 100644 docs/assets/page.js create mode 100644 docs/assets/php-toolkit.zip create mode 100644 docs/assets/style.css create mode 100644 docs/blockparser/index.html create mode 100644 docs/blueprints/index.html create mode 100644 docs/bytestream/index.html create mode 100644 docs/cli/index.html create mode 100644 docs/coding-standards/index.html create mode 100644 docs/corsproxy/index.html create mode 100644 docs/dataliberation/index.html create mode 100644 docs/encoding/index.html create mode 100644 docs/filesystem/index.html create mode 100644 docs/git/index.html create mode 100644 docs/html/index.html create mode 100644 docs/httpclient/index.html create mode 100644 docs/httpserver/index.html create mode 100644 docs/index.html create mode 100644 docs/markdown/index.html create mode 100644 docs/merge/index.html create mode 100644 docs/polyfill/index.html create mode 100644 docs/xml/index.html create mode 100644 docs/zip/index.html diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..2fb740ede --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,60 @@ +name: Deploy docs to GitHub Pages + +on: + push: + branches: [trunk] + paths: + - 'components/**' + - 'docs/**' + - 'bin/build-docs*' + - 'composer.json' + - 'composer.lock' + - '.github/workflows/docs.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: composer + coverage: none + + - name: Install dependencies + run: composer install --no-dev --optimize-autoloader --no-progress + + - name: Bundle toolkit and regenerate docs + run: | + mkdir -p docs/assets + rm -f docs/assets/php-toolkit.zip + zip -qr docs/assets/php-toolkit.zip components vendor bootstrap.php composer.json \ + -x "*/Tests/*" "*/tests/*" "*/.git/*" "*/.github/*" "*/node_modules/*" + python3 bin/build-docs.py + + - uses: actions/upload-pages-artifact@v3 + with: + path: ./docs + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/bin/build-docs-bundle.sh b/bin/build-docs-bundle.sh new file mode 100755 index 000000000..f7bf91b6c --- /dev/null +++ b/bin/build-docs-bundle.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Rebuilds docs/assets/php-toolkit.zip and regenerates the docs HTML pages. +# Run this whenever components/ changes or the docs page generator (bin/build-docs.py) +# changes. +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> composer install --no-dev --optimize-autoloader" +composer install --no-dev --optimize-autoloader --quiet + +echo "==> bundling docs/assets/php-toolkit.zip" +rm -f docs/assets/php-toolkit.zip +zip -qr docs/assets/php-toolkit.zip components vendor bootstrap.php composer.json \ + -x "*/Tests/*" "*/tests/*" "*/.git/*" "*/.github/*" "*/node_modules/*" + +echo "==> generating docs/*/index.html" +python3 bin/build-docs.py + +echo "Done. docs/assets/php-toolkit.zip = $(du -h docs/assets/php-toolkit.zip | cut -f1)" diff --git a/bin/build-docs.py b/bin/build-docs.py new file mode 100755 index 000000000..b4616884e --- /dev/null +++ b/bin/build-docs.py @@ -0,0 +1,856 @@ +#!/usr/bin/env python3 +""" +Generates docs//index.html for every component listed below from +a single template, plus the docs/index.html landing page. + +Each component entry is a tuple of: + (slug, title, lede, install_pkg, sections) + +`sections` is a list of (heading, body_html, snippet_or_none) — body_html may +contain HTML; snippet is a (filename, php_code) tuple or None. + +The PHP snippets here are derived from each component's own README. They run +inside WordPress Playground via ``, +which loads docs/assets/php-toolkit.zip and the toolkit's vendor/autoload.php. +""" + +import os +import re +import sys +from html import escape as h +from textwrap import dedent + +DOCS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'docs') + +PAGE_HEAD = ''' + + + + +{title} — PHP Toolkit + + + + + + + +
+\tPHP Toolkit +\t +
+''' + +PAGE_FOOT = ''' + + +''' + + +def snippet_block(name, code): + code_html = h(code).rstrip() + return ( + f'\n' + f'\n' + f'\n' + ) + + +def render_component(slug, title, lede, install, sections): + out = [PAGE_HEAD.format(title=h(title), description=h(lede))] + out.append('\n') + out.append('
\n') + out.append('\t\n') + out.append('\t
\n') + out.append(f'\t\t

{h(title)}

\n') + out.append(f'\t\t

{lede}

\n') + if install: + out.append(f'\t\tcomposer require {h(install)}\n') + out.append( + '\t\t

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

{h(heading)}

\n') + if body_html: + out.append(f'\t\t{body_html}\n') + if snippet: + name, code = snippet + out.append(snippet_block(name, code)) + out.append( + '\t\t

' + f'Full API reference: {h(title)} README.

\n' + ) + out.append('\t
\n
\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.

', + ('lazy-load-images.php', php('''$html = '
+\tHero +\tInline +
'; + +$tags = new WP_HTML_Tag_Processor( $html ); +while ( $tags->next_tag( 'img' ) ) { +\t$tags->set_attribute( 'loading', 'lazy' ); +\t$tags->add_class( 'responsive' ); +} + +echo $tags->get_updated_html();'''))), + ('Query by tag and class', + '

Pass an array to next_tag() to find tags matching a tag name, a CSS class, or both. The processor never builds a DOM; it just advances a cursor.

', + ('query-tags.php', php('''$html = '
  • Buy milk
  • ' +\t. '
  • Walk dog
  • ' +\t. '
  • Read book
'; + +$tags = new WP_HTML_Tag_Processor( $html ); +while ( $tags->next_tag( array( 'tag_name' => 'li', 'class_name' => 'done' ) ) ) { +\t$tags->set_attribute( 'aria-checked', 'true' ); +} +echo $tags->get_updated_html();'''))), + ('Walk the structure', + '

WP_HTML_Processor understands HTML5 tree construction. You can ask it for the current depth, breadcrumbs, and whether a tag is implicitly closed.

', + ('walk-tree.php', php('''$html = '

First

Second

  • A
  • B
'; + +$p = WP_HTML_Processor::create_fragment( $html ); +while ( $p->next_token() ) { +\tif ( $p->get_token_type() !== '#tag' ) continue; +\t$prefix = str_repeat( ' ', $p->get_current_depth() ); +\t$close = $p->is_tag_closer() ? '/' : ''; +\techo "{$prefix}<{$close}{$p->get_tag()}>\\n"; +}'''))), + ('Decode HTML entities', + '

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.

', + ('decode-entities.php', php('''$html = 'Hello & World © 2026'; + +$tags = new WP_HTML_Tag_Processor( $html ); +$tags->next_tag( 'a' ); +echo "href=" . $tags->get_attribute( 'href' ) . "\\n"; + +while ( $tags->next_token() ) { +\tif ( $tags->get_token_type() === '#text' ) { +\t\techo "text=" . $tags->get_modifiable_text() . "\\n"; +\t} +}'''))), + ('Insert and remove subtrees', + '

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.

', + ('create-zip.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\WriteStream\\FileWriteStream; +use WordPress\\Zip\\FileEntry; +use WordPress\\Zip\\ZipDecoder; +use WordPress\\Zip\\ZipEncoder; + +$path = tempnam( sys_get_temp_dir(), 'demo' ) . '.zip'; +$out = FileWriteStream::from_path( $path, 'truncate' ); +$enc = new ZipEncoder( $out ); + +foreach ( array( +\t'readme.txt' => 'Hello from the toolkit.', +\t'data/hello.json' => json_encode( array( 'ok' => true ) ), +) as $name => $body ) { +\t$enc->append_file( new FileEntry( array( +\t\t'path' => $name, +\t\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, +\t\t'body_reader' => new MemoryPipe( $body ), +\t) ) ); +} +$enc->close(); +$out->close_writing(); + +$bytes = file_get_contents( $path ); +printf( "Wrote %d bytes, %d entries.\\n", strlen( $bytes ), substr_count( $bytes, "PK\\x01\\x02" ) );'''))), + ('Read entries through a filesystem', + '

ZipFilesystem implements the toolkit\'s Filesystem interface, so you can ls(), is_file(), and get_contents() as if the archive were a directory tree.

', + ('read-zip.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\FileReadStream; +use WordPress\\ByteStream\\WriteStream\\FileWriteStream; +use WordPress\\Zip\\FileEntry; +use WordPress\\Zip\\ZipDecoder; +use WordPress\\Zip\\ZipEncoder; +use WordPress\\Zip\\ZipFilesystem; + +// Build an archive on the fly so the example is self-contained. +$path = tempnam( sys_get_temp_dir(), 'demo' ) . '.zip'; +$out = FileWriteStream::from_path( $path, 'truncate' ); +$enc = new ZipEncoder( $out ); +foreach ( array( +\t'mimetype' => 'application/epub+zip', +\t'EPUB/package.opf' => '', +) as $name => $body ) { +\t$enc->append_file( new FileEntry( array( +\t\t'path' => $name, +\t\t'compression_method' => ZipDecoder::COMPRESSION_NONE, +\t\t'body_reader' => new MemoryPipe( $body ), +\t) ) ); +} +$enc->close(); +$out->close_writing(); + +$zip = ZipFilesystem::create( FileReadStream::from_path( $path ) ); +foreach ( $zip->ls() as $entry ) { +\techo $zip->is_dir( $entry ) +\t\t? "[dir] {$entry}\\n" +\t\t: "{$entry}: " . $zip->get_contents( $entry ) . "\\n"; +}'''))), + ('Stream a large file out of an archive', + '

For multi-megabyte entries inside an archive, use open_read_stream() instead of loading the whole file. The decoder inflates as you pull.

', + ('stream-large-entry.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\FileReadStream; +use WordPress\\ByteStream\\WriteStream\\FileWriteStream; +use WordPress\\Zip\\FileEntry; +use WordPress\\Zip\\ZipDecoder; +use WordPress\\Zip\\ZipEncoder; +use WordPress\\Zip\\ZipFilesystem; + +$path = tempnam( sys_get_temp_dir(), 'demo' ) . '.zip'; +$out = FileWriteStream::from_path( $path, 'truncate' ); +$enc = new ZipEncoder( $out ); +$enc->append_file( new FileEntry( array( +\t'path' => 'big.csv', +\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, +\t'body_reader' => new MemoryPipe( str_repeat( "id,value\\n1,foo\\n2,bar\\n", 200 ) ), +) ) ); +$enc->close(); +$out->close_writing(); + +$zip = ZipFilesystem::create( FileReadStream::from_path( $path ) ); +$stream = $zip->open_read_stream( 'big.csv' ); +$lines = 0; +while ( ! $stream->reached_end_of_data() ) { +\t$n = $stream->pull( 4096 ); +\tif ( $n <= 0 ) break; +\t$lines += substr_count( $stream->consume( $n ), "\\n" ); +} +echo "Streamed {$lines} lines.\\n";'''))), + ])) + +# ---------- ByteStream ---------- +COMPONENTS.append(('bytestream', 'ByteStream', + 'Composable streaming primitives for reading, writing, and transforming byte data. Pull-based, peek-friendly, and zero-copy where it matters.', + 'wp-php-toolkit/bytestream', + [ + ('The model', + '

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.

', + ('read-file.php', php('''use WordPress\\ByteStream\\ReadStream\\FileReadStream; + +// Make a sample file inside the snippet so it is self-contained. +$path = tempnam( sys_get_temp_dir(), 'sample' ); +file_put_contents( $path, str_repeat( "line of text\\n", 50 ) ); + +$reader = FileReadStream::from_path( $path ); +$total = 0; +while ( ! $reader->reached_end_of_data() ) { +\t$n = $reader->pull( 64 ); +\tif ( $n <= 0 ) break; +\t$total += strlen( $reader->consume( $n ) ); +} +$reader->close_reading(); +echo "Read {$total} bytes.\\n";'''))), + ('Memory pipes', + '

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.

', + ('memory-pipe.php', php('''use WordPress\\ByteStream\\MemoryPipe; + +$pipe = new MemoryPipe(); +$pipe->append_bytes( "first line\\n" ); +$pipe->append_bytes( "second line\\n" ); +$pipe->close_writing(); + +while ( ! $pipe->reached_end_of_data() ) { +\t$n = $pipe->pull( 1024 ); +\tif ( $n <= 0 ) break; +\techo "chunk: " . $pipe->consume( $n ); +}'''))), + ('Transform on the fly', + '

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.

', + ('in-memory.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; + +$fs = InMemoryFilesystem::create(); +$fs->mkdir( '/src/components', array( 'recursive' => true ) ); +$fs->put_contents( '/src/components/button.php', 'put_contents( '/src/components/form.php', 'ls( '/src/components' ) );'''))), + ('Local disk', + '

LocalFilesystem::create($root) chroots all paths to $root. The forward-slash convention is enforced even on Windows.

', + ('local.php', php('''use WordPress\\Filesystem\\LocalFilesystem; + +$root = sys_get_temp_dir() . '/toolkit-demo'; +$fs = LocalFilesystem::create( $root ); + +$fs->put_contents( '/hello.txt', "Hi!\\n" ); +echo $fs->get_contents( '/hello.txt' ); +echo "exists? " . ( $fs->exists( '/hello.txt' ) ? 'yes' : 'no' ) . "\\n";'''))), + ('SQLite-backed', + '

Everything lives in a single SQLite file. Convenient for portable scratch storage that survives a process boundary.

', + ('sqlite.php', php('''use WordPress\\Filesystem\\SQLiteFilesystem; + +$fs = SQLiteFilesystem::create( ':memory:' ); +$fs->put_contents( '/notes.md', "# Hello\\n\\nFrom SQLite." ); +echo $fs->get_contents( '/notes.md' );'''))), + ('Walk a tree', + '

The interface includes a recursive walker so you can iterate every file regardless of backend.

', + ('walk.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; + +$fs = InMemoryFilesystem::create(); +foreach ( array( +\t'/a.txt' => 'A', +\t'/dir/b.txt' => 'B', +\t'/dir/sub/c.txt' => 'C', +) as $path => $body ) { +\t$fs->mkdir( dirname( $path ), array( 'recursive' => true ) ); +\t$fs->put_contents( $path, $body ); +} + +$walker = function ( $dir ) use ( $fs, &$walker ) { +\tforeach ( $fs->ls( $dir ) as $name ) { +\t\t$full = rtrim( $dir, '/' ) . '/' . $name; +\t\tif ( $fs->is_dir( $full ) ) $walker( $full ); +\t\telse echo "{$full}\\n"; +\t} +}; +$walker( '/' );'''))), + ])) + +# ---------- BlockParser ---------- +COMPONENTS.append(('blockparser', 'BlockParser', + 'WordPress core\'s block parser as a standalone library. Same parser, no WP dependency.', + 'wp-php-toolkit/blockparser', + [ + ('Parse block markup', + '

Pass the parser any HTML containing block delimiters and get back a structured array — blockName, attrs, innerBlocks, innerHTML, innerContent.

', + ('parse-blocks.php', php('''$document = "\\n

Welcome

\\n\\n\\n\\n

Hello from the block editor.

\\n"; + +$blocks = ( new WP_Block_Parser() )->parse( $document ); +foreach ( $blocks as $block ) { +\tif ( $block['blockName'] ) { +\t\techo "{$block['blockName']}: " . trim( strip_tags( $block['innerHTML'] ) ) . "\\n"; +\t} +}'''))), + ('Self-closing blocks', + '

Void blocks like core/spacer end with /-->. They have no inner HTML, just attributes.

', + ('void-blocks.php', php('''$blocks = ( new WP_Block_Parser() )->parse( +\t'' +); +print_r( $blocks[0] );'''))), + ('Walk nested blocks', + '

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).

', + ('blocks-to-md.php', php('''use WordPress\\Markdown\\MarkdownConsumer; +use WordPress\\Markdown\\MarkdownProducer; + +$source = "## Setup\\n\\n1. Install\\n2. Configure\\n3. Profit"; +$blocks = ( new MarkdownConsumer( $source ) )->consume()->get_block_markup(); +$round = new MarkdownProducer( $blocks ); +echo $round->produce();'''))), + ('Frontmatter', + '

YAML frontmatter is parsed and exposed as metadata so the block markup stays clean.

', + ('frontmatter.php', php('''use WordPress\\Markdown\\MarkdownConsumer; + +$markdown = <<consume(); +print_r( $result->get_all_metadata() ); +echo "\\n--- block markup ---\\n"; +echo $result->get_block_markup();'''))), + ])) + +# ---------- XML ---------- +COMPONENTS.append(('xml', 'XML', + 'Streaming XML processor without libxml2. Modify attributes, walk namespaces, scan large documents without loading them into memory.', + 'wp-php-toolkit/xml', + [ + ('Read and rewrite an attribute', + '

The XMLProcessor mirrors the HTML tag processor — find a tag, read or set attributes, get the modified document back.

', + ('rewrite-attr.php', php('''use WordPress\\XML\\XMLProcessor; + +$xml = 'PHP Internals'; +$p = XMLProcessor::create_from_string( $xml ); + +if ( $p->next_tag( 'book' ) ) { +\techo "before: " . $p->get_attribute( '', 'price' ) . "\\n"; +\t$p->set_attribute( '', 'price', '24.99' ); +} +echo $p->get_updated_xml();'''))), + ('Namespaces are first-class', + '

Methods take a namespace URI as the first argument, never a prefix. The processor resolves prefixes itself.

', + ('namespaces.php', php('''use WordPress\\XML\\XMLProcessor; + +$xml = '' +\t. 'Content'; + +$p = XMLProcessor::create_from_string( $xml ); +$ns = 'http://wordpress.org/export/1.2/'; +if ( $p->next_tag( array( $ns, 'post' ) ) ) { +\techo "tag: " . $p->get_tag_local_name() . "\\n"; +\techo "status: " . $p->get_attribute( $ns, 'status' ) . "\\n"; +\t$p->set_attribute( $ns, 'status', 'published' ); +} +echo $p->get_updated_xml();'''))), + ])) + +# ---------- Encoding ---------- +COMPONENTS.append(('encoding', 'Encoding', + 'Pure-PHP UTF-8 validation and scrubbing. Detects malformed bytes, replaces them per the Unicode maximal-subpart algorithm, and works without mbstring.', + 'wp-php-toolkit/encoding', + [ + ('Validate', None, + ('validate.php', php('''use function WordPress\\Encoding\\wp_is_valid_utf8; + +var_dump( wp_is_valid_utf8( 'plain ASCII' ) ); // true +var_dump( wp_is_valid_utf8( "Pencil: \\xE2\\x9C\\x8F" ) ); // true +var_dump( wp_is_valid_utf8( "stray \\xC0 byte" ) ); // false +var_dump( wp_is_valid_utf8( "\\xC1\\xBF" ) ); // false (overlong)'''))), + ('Scrub invalid bytes', '

Replace each maximal subpart with U+FFFD.

', + ('scrub.php', php('''use function WordPress\\Encoding\\wp_scrub_utf8; + +echo wp_scrub_utf8( "caf\\xC0 latte" ) . "\\n"; // caf? latte +echo wp_scrub_utf8( ".\\xE2\\x8C." ) . "\\n"; // .?. (incomplete) +echo wp_scrub_utf8( ".\\xC1\\xBF." ) . "\\n"; // .??. (two subparts)'''))), + ('Detect noncharacters', '

Code points like U+FFFE that should never appear in interchange.

', + ('noncharacters.php', php('''use function WordPress\\Encoding\\wp_has_noncharacters; + +var_dump( wp_has_noncharacters( "Plain text" ) ); // false +var_dump( wp_has_noncharacters( "\\xEF\\xBF\\xBE" ) ); // true (U+FFFE)'''))), + ])) + +# ---------- Polyfill ---------- +COMPONENTS.append(('polyfill', 'Polyfill', + 'PHP 8 string functions on PHP 7.2+, WordPress hook stubs, and translation/escaping passthroughs so toolkit code runs without WordPress.', + 'wp-php-toolkit/polyfill', + [ + ('PHP 8 strings on 7.2', + '

Polyfills are loaded automatically through Composer\'s autoload.files. They define functions only when missing, so they\'re safe to use alongside PHP 8.

', + ('php8-strings.php', php('''var_dump( str_starts_with( '/var/www', '/var' ) ); +var_dump( str_ends_with( 'image.png', '.png' ) ); +var_dump( str_contains( 'WordPress Toolkit', 'Toolkit' ) );'''))), + ('WordPress hooks without WordPress', + '

A minimal but real implementation of add_filter, apply_filters, and the action equivalents — priorities and all.

', + ('hooks.php', php('''add_filter( 'the_title', 'strtoupper' ); +add_filter( 'the_title', function ( $title ) { +\treturn '> ' . $title; +}, 20 ); + +echo apply_filters( 'the_title', 'hello world' ) . "\\n";'''))), + ])) + +# ---------- CLI ---------- +COMPONENTS.append(('cli', 'CLI', + 'POSIX-style argument parser. Long options, short bundles, inline values, positional args — one static call.', + 'wp-php-toolkit/cli', + [ + ('Parse argv', + '

Define options as a four-tuple of [ short, has_value, default, description ], pass argv, get back positionals and an option map.

', + ('parse-args.php', php('''use WordPress\\CLI\\CLI; + +$option_defs = array( +\t'output' => array( 'o', true, null, 'Output file path' ), +\t'force' => array( 'f', false, false, 'Overwrite existing files' ), +\t'verbose' => array( 'v', false, false, 'Verbose output' ), +); + +$argv = array( '--output=/tmp/result.txt', '-fv', 'input.json' ); +list( $positionals, $options ) = CLI::parse_command_args_and_options( $argv, $option_defs ); + +print_r( array( +\t'positionals' => $positionals, +\t'options' => $options, +) );'''))), + ])) + +# ---------- Git ---------- +COMPONENTS.append(('git', 'Git', + 'A pure-PHP Git client and server. Commits, branches, diffs, HTTP push/pull — all without shelling out to git.', + 'wp-php-toolkit/git', + [ + ('Commit files in memory', + '

The repository builds the blob, tree, and commit objects for you. Backed by any Filesystem, including InMemoryFilesystem for ephemeral repos.

', + ('commit.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Git\\GitRepository; + +$repo = new GitRepository( InMemoryFilesystem::create() ); + +$oid = $repo->commit( array( +\t'updates' => array( +\t\t'README.md' => '# My Project', +\t\t'src/hello-world.php' => 'read_object_by_path( '/README.md' )->consume_all();'''))), + ('Read objects by hash', + '

Every Git object is identified by its SHA-1. Store a blob, get the hash back, read it later.

', + ('objects.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Git\\GitRepository; + +$repo = new GitRepository( InMemoryFilesystem::create() ); +$blob = $repo->add_object( 'blob', 'Hello, world!' ); +echo "oid: {$blob}\\n"; + +$reader = $repo->read_object( $blob ); +$reader->pull( 8096 ); +echo $reader->peek( 8096 ) . "\\n";'''))), + ])) + +# ---------- Merge ---------- +COMPONENTS.append(('merge', 'Merge', + 'Three-way merge and diff. Pluggable differ + merger + optional validator.', + 'wp-php-toolkit/merge', + [ + ('Merge two branches', + '

Give it a base, branch A, and branch B. Get a merge result with conflicts (if any) and the merged content.

', + ('three-way.php', php('''use WordPress\\Merge\\Diff\\LineDiffer; +use WordPress\\Merge\\Merge\\LineMerger; +use WordPress\\Merge\\MergeStrategy; + +$strategy = new MergeStrategy( new LineDiffer(), new LineMerger() ); + +$result = $strategy->merge( +\t"Line 1\\nLine 2\\nLine 3\\n", +\t"Line 1\\nLine 2 modified\\nLine 3\\n", +\t"Line 1\\nLine 2\\nLine 3\\nLine 4\\n" +); + +echo $result->get_merged_content();'''))), + ('Inspect a diff', + '

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.

', + ('apply-step.php', php('''use WordPress\\Blueprints\\Runner; +use WordPress\\Blueprints\\RunnerConfiguration; + +$config = ( new RunnerConfiguration() ) +\t->set_execution_mode( Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) +\t->set_target_site_root( '/wordpress' ) +\t->set_target_site_url( 'http://playground.test/' ); + +echo "Configured runner for: " . $config->get_target_site_root() . "\\n"; +echo "Mode: " . $config->get_execution_mode() . "\\n";'''))), + ])) + +# ---------- DataLiberation ---------- +COMPONENTS.append(('dataliberation', 'DataLiberation', + 'Streaming WordPress import/export. WXR, SQL, block markup — without loading whole datasets into memory.', + 'wp-php-toolkit/data-liberation', + [ + ('Write a WXR export', + '

Feed entities to WXRWriter in logical order: post first, then meta/terms/comments belonging to it.

', + ('wxr-writer.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\DataLiberation\\EntityWriter\\WXRWriter; +use WordPress\\DataLiberation\\ImportEntity; + +$output = new MemoryPipe(); +$writer = new WXRWriter( $output ); + +$writer->append_entity( new ImportEntity( 'post', array( +\t'post_title' => 'Hello World', +\t'post_date' => '2024-01-15', +\t'guid' => 'https://example.com/?p=1', +\t'content' => '

Welcome to my site.

', +\t'post_id' => '1', +\t'post_name' => 'hello-world', +\t'status' => 'publish', +\t'post_type' => 'post', +) ) ); + +$writer->finalize(); +$writer->close_writing(); +$output->close_writing(); + +echo $output->consume_all();'''))), + ])) + +# ---------- ToolkitCodingStandards ---------- +COMPONENTS.append(('coding-standards', 'ToolkitCodingStandards', + 'PHP_CodeSniffer sniffs used by this project: enforce Yoda comparisons, ban the short ternary.', + 'wp-php-toolkit/toolkit-coding-standards', + [ + ('How to use it', + '

This component is a phpcs ruleset, not runtime code, so there\'s nothing to demo in Playground. Reference the standard from your phpcs.xml:

' + '
<ruleset>\n  <rule ref="WordPressToolkitCodingStandards"/>\n</ruleset>
' + '

See the README for individual sniff selection and example fixes.

', + None), + ])) + + +def render_index(): + cards = [] + for slug, title, lede, _, _ in COMPONENTS: + # one-line description: strip HTML, take first sentence, cap length + clean = re.sub(r'<[^>]+>', '', lede) + first = clean.split('.')[0] + if len(first) > 110: + first = first[:107] + '…' + cards.append( + f'\t\t
  • {h(title)}' + f'{h(first)}.
  • ' + ) + cards_html = '\n'.join(cards) + return f''' + + + + +PHP Toolkit — runnable docs + + + + +
    +\tPHP Toolkit +\t +
    +
    +\t

    PHP Toolkit

    +\t

    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.

    + + + +

    Full API reference: BlockParser README.

    +
    +
    + + + diff --git a/docs/blueprints/index.html b/docs/blueprints/index.html new file mode 100644 index 000000000..bdca2e16f --- /dev/null +++ b/docs/blueprints/index.html @@ -0,0 +1,57 @@ + + + + + +Blueprints — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    Blueprints

    +

    Declarative WordPress site provisioning. Write a JSON description of plugins, options, and content; let the runner execute it.

    + composer require wp-php-toolkit/blueprints +

    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.

    +

    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.

    +

    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.

    + + + +

    Full API reference: Blueprints README.

    +
    +
    + + + diff --git a/docs/bytestream/index.html b/docs/bytestream/index.html new file mode 100644 index 000000000..eba769d67 --- /dev/null +++ b/docs/bytestream/index.html @@ -0,0 +1,113 @@ + + + + + +ByteStream — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    ByteStream

    +

    Composable streaming primitives for reading, writing, and transforming byte data. Pull-based, peek-friendly, and zero-copy where it matters.

    + composer require wp-php-toolkit/bytestream +

    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.

    +

    The model

    +

    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.

    +

    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.

    + + + +

    Memory pipes

    +

    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.

    + + + +

    Transform on the fly

    +

    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.

    + + + +

    Full API reference: ByteStream README.

    +
    +
    + + + diff --git a/docs/cli/index.html b/docs/cli/index.html new file mode 100644 index 000000000..83bf38c66 --- /dev/null +++ b/docs/cli/index.html @@ -0,0 +1,60 @@ + + + + + +CLI — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    CLI

    +

    POSIX-style argument parser. Long options, short bundles, inline values, positional args — one static call.

    + composer require wp-php-toolkit/cli +

    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 argv

    +

    Define options as a four-tuple of [ short, has_value, default, description ], pass argv, get back positionals and an option map.

    + + + +

    Full API reference: CLI README.

    +
    +
    + + + diff --git a/docs/coding-standards/index.html b/docs/coding-standards/index.html new file mode 100644 index 000000000..79f1da34a --- /dev/null +++ b/docs/coding-standards/index.html @@ -0,0 +1,40 @@ + + + + + +ToolkitCodingStandards — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    ToolkitCodingStandards

    +

    PHP_CodeSniffer sniffs used by this project: enforce Yoda comparisons, ban the short ternary.

    + composer require wp-php-toolkit/toolkit-coding-standards +

    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.

    +

    How to use it

    +

    This component is a phpcs ruleset, not runtime code, so there's nothing to demo in Playground. Reference the standard from your phpcs.xml:

    <ruleset>
    +  <rule ref="WordPressToolkitCodingStandards"/>
    +</ruleset>

    See the README for individual sniff selection and example fixes.

    +

    Full API reference: ToolkitCodingStandards README.

    +
    +
    + + + diff --git a/docs/corsproxy/index.html b/docs/corsproxy/index.html new file mode 100644 index 000000000..cf3f7f6e6 --- /dev/null +++ b/docs/corsproxy/index.html @@ -0,0 +1,38 @@ + + + + + +CORSProxy — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    CORSProxy

    +

    A small PHP CORS proxy intended for browser-side code that needs to reach servers without CORS headers.

    + composer require wp-php-toolkit/corsproxy +

    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.

    +

    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.

    +

    Full API reference: CORSProxy README.

    +
    +
    + + + diff --git a/docs/dataliberation/index.html b/docs/dataliberation/index.html new file mode 100644 index 000000000..17272d42a --- /dev/null +++ b/docs/dataliberation/index.html @@ -0,0 +1,68 @@ + + + + + +DataLiberation — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    DataLiberation

    +

    Streaming WordPress import/export. WXR, SQL, block markup — without loading whole datasets into memory.

    + composer require wp-php-toolkit/data-liberation +

    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.

    +

    Write a WXR export

    +

    Feed entities to WXRWriter in logical order: post first, then meta/terms/comments belonging to it.

    + + + +

    Full API reference: DataLiberation README.

    +
    +
    + + + diff --git a/docs/encoding/index.html b/docs/encoding/index.html new file mode 100644 index 000000000..c937feba9 --- /dev/null +++ b/docs/encoding/index.html @@ -0,0 +1,77 @@ + + + + + +Encoding — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    Encoding

    +

    Pure-PHP UTF-8 validation and scrubbing. Detects malformed bytes, replaces them per the Unicode maximal-subpart algorithm, and works without mbstring.

    + composer require wp-php-toolkit/encoding +

    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.

    +

    Validate

    + + + +

    Scrub invalid bytes

    +

    Replace each maximal subpart with U+FFFD.

    + + + +

    Detect noncharacters

    +

    Code points like U+FFFE that should never appear in interchange.

    + + + +

    Full API reference: Encoding README.

    +
    +
    + + + diff --git a/docs/filesystem/index.html b/docs/filesystem/index.html new file mode 100644 index 000000000..d14cb2508 --- /dev/null +++ b/docs/filesystem/index.html @@ -0,0 +1,115 @@ + + + + + +Filesystem — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    Filesystem

    +

    A unified filesystem abstraction across local disk, in-memory trees, SQLite, and ZIP archives. Forward-slash paths everywhere, even on Windows.

    + composer require wp-php-toolkit/filesystem +

    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.

    +

    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.

    +

    In-memory tree

    +

    The fastest backend. Stores everything in PHP arrays; ideal for tests and ephemeral processing.

    + + + +

    Local disk

    +

    LocalFilesystem::create($root) chroots all paths to $root. The forward-slash convention is enforced even on Windows.

    + + + +

    SQLite-backed

    +

    Everything lives in a single SQLite file. Convenient for portable scratch storage that survives a process boundary.

    + + + +

    Walk a tree

    +

    The interface includes a recursive walker so you can iterate every file regardless of backend.

    + + + +

    Full API reference: Filesystem README.

    +
    +
    + + + diff --git a/docs/git/index.html b/docs/git/index.html new file mode 100644 index 000000000..087e846cd --- /dev/null +++ b/docs/git/index.html @@ -0,0 +1,78 @@ + + + + + +Git — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    Git

    +

    A pure-PHP Git client and server. Commits, branches, diffs, HTTP push/pull — all without shelling out to git.

    + composer require wp-php-toolkit/git +

    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.

    +

    Commit files in memory

    +

    The repository builds the blob, tree, and commit objects for you. Backed by any Filesystem, including InMemoryFilesystem for ephemeral repos.

    + + + +

    Read objects by hash

    +

    Every Git object is identified by its SHA-1. Store a blob, get the hash back, read it later.

    + + + +

    Full API reference: Git README.

    +
    +
    + + + diff --git a/docs/html/index.html b/docs/html/index.html new file mode 100644 index 000000000..1b8f53782 --- /dev/null +++ b/docs/html/index.html @@ -0,0 +1,138 @@ + + + + + +HTML — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    HTML

    +

    A full HTML5 parser and tag processor in pure PHP, mirroring WordPress core's HTML API. No libxml2, no DOM extension, no external dependencies.

    + composer require wp-php-toolkit/html +

    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.

    +

    Why this exists

    +

    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.

    +

    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.

    + + + +

    Query by tag and class

    +

    Pass an array to next_tag() to find tags matching a tag name, a CSS class, or both. The processor never builds a DOM; it just advances a cursor.

    + + + +

    Walk the structure

    +

    WP_HTML_Processor understands HTML5 tree construction. You can ask it for the current depth, breadcrumbs, and whether a tag is implicitly closed.

    + + + +

    Decode HTML entities

    +

    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.

    + + + +

    Insert and remove subtrees

    +

    The full processor lets you replace, insert, or remove entire subtrees by addressing a bookmark. Useful for scrubbing posts before display.

    + + + +

    Full API reference: HTML README.

    +
    +
    + + + diff --git a/docs/httpclient/index.html b/docs/httpclient/index.html new file mode 100644 index 000000000..2802212a9 --- /dev/null +++ b/docs/httpclient/index.html @@ -0,0 +1,56 @@ + + + + + +HttpClient — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    HttpClient

    +

    Async HTTP client without curl required. Uses sockets when curl is missing, supports concurrent requests and streaming responses.

    + composer require wp-php-toolkit/http-client +

    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.

    +

    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.

    +

    GET a URL

    +

    Build a Request, hand it to Client::fetch(), await the response, read the body.

    + + + +

    Full API reference: HttpClient README.

    +
    +
    + + + diff --git a/docs/httpserver/index.html b/docs/httpserver/index.html new file mode 100644 index 000000000..8bd12d611 --- /dev/null +++ b/docs/httpserver/index.html @@ -0,0 +1,60 @@ + + + + + +HttpServer — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    HttpServer

    +

    A minimal blocking TCP HTTP server in pure PHP. For CLI tools and tests, not for production traffic.

    + composer require wp-php-toolkit/http-server +

    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.

    +

    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.

    + + + +

    Full API reference: HttpServer README.

    +
    +
    + + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 000000000..ac9ae17ce --- /dev/null +++ b/docs/index.html @@ -0,0 +1,51 @@ + + + + + +PHP Toolkit — runnable docs + + + + +
    + PHP Toolkit + +
    +
    +

    PHP Toolkit

    +

    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.

    + +

    Components

    + + +

    How these examples work

    +

    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.

    +

    The toolkit bundle (docs/assets/php-toolkit.zip, ≈1.8 MB) ships with the docs, so no third-party CDN is involved.

    +
    + + + diff --git a/docs/markdown/index.html b/docs/markdown/index.html new file mode 100644 index 000000000..eb475fa12 --- /dev/null +++ b/docs/markdown/index.html @@ -0,0 +1,94 @@ + + + + + +Markdown — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    Markdown

    +

    Bidirectional converter between Markdown and WordPress block markup. Round-trips faithfully so you can keep Markdown files and a WP database in sync.

    + composer require wp-php-toolkit/markdown +

    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.

    +

    Markdown to blocks

    +

    Pass a Markdown string to MarkdownConsumer and call consume(). The result exposes block markup and any YAML frontmatter as metadata.

    + + + +

    Blocks back to Markdown

    +

    MarkdownProducer walks block markup and emits matching Markdown. Round-tripping a document should produce equivalent output (modulo whitespace normalization).

    + + + +

    Frontmatter

    +

    YAML frontmatter is parsed and exposed as metadata so the block markup stays clean.

    + + + +

    Full API reference: Markdown README.

    +
    +
    + + + diff --git a/docs/merge/index.html b/docs/merge/index.html new file mode 100644 index 000000000..3aa7a7763 --- /dev/null +++ b/docs/merge/index.html @@ -0,0 +1,79 @@ + + + + + +Merge — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    Merge

    +

    Three-way merge and diff. Pluggable differ + merger + optional validator.

    + composer require wp-php-toolkit/merge +

    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.

    +

    Merge two branches

    +

    Give it a base, branch A, and branch B. Get a merge result with conflicts (if any) and the merged content.

    + + + +

    Inspect a diff

    +

    The Diff object is a flat list of equal/insert/delete operations.

    + + + +

    Full API reference: Merge README.

    +
    +
    + + + diff --git a/docs/polyfill/index.html b/docs/polyfill/index.html new file mode 100644 index 000000000..53459f91e --- /dev/null +++ b/docs/polyfill/index.html @@ -0,0 +1,63 @@ + + + + + +Polyfill — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    Polyfill

    +

    PHP 8 string functions on PHP 7.2+, WordPress hook stubs, and translation/escaping passthroughs so toolkit code runs without WordPress.

    + composer require wp-php-toolkit/polyfill +

    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.

    +

    PHP 8 strings on 7.2

    +

    Polyfills are loaded automatically through Composer's autoload.files. They define functions only when missing, so they're safe to use alongside PHP 8.

    + + + +

    WordPress hooks without WordPress

    +

    A minimal but real implementation of add_filter, apply_filters, and the action equivalents — priorities and all.

    + + + +

    Full API reference: Polyfill README.

    +
    +
    + + + diff --git a/docs/xml/index.html b/docs/xml/index.html new file mode 100644 index 000000000..c208004dc --- /dev/null +++ b/docs/xml/index.html @@ -0,0 +1,77 @@ + + + + + +XML — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    XML

    +

    Streaming XML processor without libxml2. Modify attributes, walk namespaces, scan large documents without loading them into memory.

    + composer require wp-php-toolkit/xml +

    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.

    +

    Read and rewrite an attribute

    +

    The XMLProcessor mirrors the HTML tag processor — find a tag, read or set attributes, get the modified document back.

    + + + +

    Namespaces are first-class

    +

    Methods take a namespace URI as the first argument, never a prefix. The processor resolves prefixes itself.

    + + + +

    Full API reference: XML README.

    +
    +
    + + + diff --git a/docs/zip/index.html b/docs/zip/index.html new file mode 100644 index 000000000..d05c3bf38 --- /dev/null +++ b/docs/zip/index.html @@ -0,0 +1,149 @@ + + + + + +Zip — PHP Toolkit + + + + + + + +
    + PHP Toolkit + +
    + +
    + +
    +

    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.

    + composer require wp-php-toolkit/zip +

    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.

    +

    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.

    +

    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.

    + + + +

    Read entries through a filesystem

    +

    ZipFilesystem implements the toolkit's Filesystem interface, so you can ls(), is_file(), and get_contents() as if the archive were a directory tree.

    + + + +

    Stream a large file out of an archive

    +

    For multi-megabyte entries inside an archive, use open_read_stream() instead of loading the whole file. The decoder inflates as you pull.

    + + + +

    Full API reference: Zip README.

    +
    +
    + + + From 4620d67050f2477d79e8bfa4e1aa27a45405bde5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 28 Apr 2026 20:41:34 +0200 Subject: [PATCH 2/7] Fix snippet entity escaping and add cross-component sidebar nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHP snippets were rendered through HTML-escape, but \n' + f'\n' f'\n' ) def render_component(slug, title, lede, install, sections): + # Component sidebar: all sibling pages, with the current one highlighted. + nav_items = [] + for s, t, _, _, _ in COMPONENTS: + cls = ' class="current"' if s == slug else '' + nav_items.append(f'\t\t\t{h(t)}') + components_nav = ( + '\t\n' + ) + out = [PAGE_HEAD.format(title=h(title), description=h(lede))] - out.append('\n') out.append('
    \n') - out.append('\t\n') + out.append(components_nav) out.append('\t
    \n') out.append(f'\t\t

    {h(title)}

    \n') out.append(f'\t\t

    {lede}

    \n') diff --git a/docs/assets/page.js b/docs/assets/page.js index d3eb410c9..3601d9648 100644 --- a/docs/assets/page.js +++ b/docs/assets/page.js @@ -158,18 +158,18 @@ }); } - // ---------- TOC mobile toggle ---------- + // ---------- Sidebar mobile toggle ---------- function wireTocToggle() { - const toggle = document.querySelector('.toc-toggle'); - const toc = document.querySelector('.toc'); - if (!toggle || !toc) return; + const toggle = document.querySelector('.sidebar-toggle'); + const sidebar = document.querySelector('.sidebar'); + if (!toggle || !sidebar) return; toggle.addEventListener('click', function () { - toc.hidden = !toc.hidden; + sidebar.classList.toggle('open'); + toggle.setAttribute( + 'aria-expanded', + sidebar.classList.contains('open') ? 'true' : 'false' + ); }); - // Hidden by default on small screens. - if (window.matchMedia('(max-width: 880px)').matches) { - toc.hidden = true; - } } if (document.readyState === 'loading') { diff --git a/docs/assets/style.css b/docs/assets/style.css index fd6e06014..0b72c75c6 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -141,14 +141,7 @@ footer.site { padding: 2.5rem 1.5rem 4rem; } -@media (max-width: 880px) { - .layout { grid-template-columns: 1fr; gap: 1rem; padding-top: 1.5rem; } - .toc { position: static !important; max-height: none !important; padding: 0 !important; border-left: 0 !important; border-top: 1px solid var(--border); padding-top: 1rem !important; } - .toc-toggle { display: block !important; } - .toc[hidden] { display: none; } -} - -.toc { +.sidebar { position: sticky; top: 5rem; align-self: start; @@ -159,6 +152,56 @@ footer.site { line-height: 1.5; } +.components-nav { + margin-bottom: 1.75rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border); +} + +.components-nav summary { + text-transform: uppercase; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--muted); + cursor: pointer; + list-style: none; + margin: 0 0 0.6rem; + user-select: none; +} + +.components-nav summary::-webkit-details-marker { display: none; } +.components-nav summary::before { + content: "▸"; + display: inline-block; + width: 1em; + transition: transform 0.15s; + color: var(--muted); +} +.components-nav[open] summary::before { transform: rotate(90deg); } + +.components-nav ol { list-style: none; padding: 0; margin: 0; } +.components-nav li { margin: 0; } +.components-nav a { + display: block; + padding: 0.18rem 0.65rem; + color: var(--toc-fg); + border-left: 2px solid transparent; + margin-left: -0.65rem; + font-size: 0.85rem; +} +.components-nav a:hover { color: var(--fg); text-decoration: none; } +.components-nav li.current a { + color: var(--toc-active); + border-left-color: var(--toc-active); + font-weight: 600; +} + +.toc { + font-size: 0.88rem; + line-height: 1.5; +} + .toc-title { text-transform: uppercase; font-size: 0.72rem; @@ -181,7 +224,7 @@ footer.site { .toc a:hover { color: var(--fg); text-decoration: none; } .toc a.active { color: var(--toc-active); border-left-color: var(--toc-active); font-weight: 500; } -.toc-toggle { +.sidebar-toggle { display: none; background: transparent; border: 1px solid var(--border); @@ -193,6 +236,20 @@ footer.site { margin-bottom: 0.5rem; } +@media (max-width: 880px) { + .layout { grid-template-columns: 1fr; gap: 1rem; padding-top: 1.5rem; } + .sidebar { + position: static; + max-height: none; + padding: 0; + } + .sidebar-toggle { display: block; } + .sidebar > .components-nav, + .sidebar > .toc { display: none; } + .sidebar.open > .components-nav, + .sidebar.open > .toc { display: block; } +} + .content { min-width: 0; max-width: 720px; diff --git a/docs/blockparser/index.html b/docs/blockparser/index.html index 4287ed3b5..2e19ad6e8 100644 --- a/docs/blockparser/index.html +++ b/docs/blockparser/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    BlockParser

    WordPress core's block parser as a standalone library. Same parser, no WP dependency.

    @@ -30,15 +55,15 @@

    Parse block markup

    Pass the parser any HTML containing block delimiters and get back a structured array — blockName, attrs, innerBlocks, innerHTML, innerContent.

    @@ -47,11 +72,11 @@

    Self-closing blocks

    Void blocks like core/spacer end with /-->. They have no inner HTML, just attributes.

    @@ -60,17 +85,17 @@

    Walk nested blocks

    Inner blocks recurse with the same shape, so a depth-first walk is a small recursive function.

    Full API reference: Blueprints README.

    diff --git a/docs/bytestream/index.html b/docs/bytestream/index.html index eba769d67..106af41e3 100644 --- a/docs/bytestream/index.html +++ b/docs/bytestream/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    ByteStream

    Composable streaming primitives for reading, writing, and transforming byte data. Pull-based, peek-friendly, and zero-copy where it matters.

    @@ -32,44 +57,44 @@

    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.

    Memory pipes

    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.

    @@ -77,30 +102,30 @@

    Transform on the fly

    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.

    Full API reference: ByteStream README.

    diff --git a/docs/cli/index.html b/docs/cli/index.html index 83bf38c66..6fe26784a 100644 --- a/docs/cli/index.html +++ b/docs/cli/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    CLI

    POSIX-style argument parser. Long options, short bundles, inline values, positional args — one static call.

    @@ -30,23 +55,23 @@

    Parse argv

    Define options as a four-tuple of [ short, has_value, default, description ], pass argv, get back positionals and an option map.

    diff --git a/docs/coding-standards/index.html b/docs/coding-standards/index.html index 79f1da34a..929c7dbbb 100644 --- a/docs/coding-standards/index.html +++ b/docs/coding-standards/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    ToolkitCodingStandards

    PHP_CodeSniffer sniffs used by this project: enforce Yoda comparisons, ban the short ternary.

    diff --git a/docs/corsproxy/index.html b/docs/corsproxy/index.html index cf3f7f6e6..a1a0ca4e3 100644 --- a/docs/corsproxy/index.html +++ b/docs/corsproxy/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    CORSProxy

    A small PHP CORS proxy intended for browser-side code that needs to reach servers without CORS headers.

    diff --git a/docs/dataliberation/index.html b/docs/dataliberation/index.html index 17272d42a..b6109bfc4 100644 --- a/docs/dataliberation/index.html +++ b/docs/dataliberation/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    DataLiberation

    Streaming WordPress import/export. WXR, SQL, block markup — without loading whole datasets into memory.

    @@ -30,8 +55,8 @@

    Write a WXR export

    Feed entities to WXRWriter in logical order: post first, then meta/terms/comments belonging to it.

    Full API reference: DataLiberation README.

    diff --git a/docs/encoding/index.html b/docs/encoding/index.html index c937feba9..5a11a29b3 100644 --- a/docs/encoding/index.html +++ b/docs/encoding/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    Encoding

    Pure-PHP UTF-8 validation and scrubbing. Detects malformed bytes, replaces them per the Unicode maximal-subpart algorithm, and works without mbstring.

    @@ -29,42 +54,42 @@

    Encoding

    Validate

    Scrub invalid bytes

    Replace each maximal subpart with U+FFFD.

    Detect noncharacters

    Code points like U+FFFE that should never appear in interchange.

    Full API reference: Encoding README.

    diff --git a/docs/filesystem/index.html b/docs/filesystem/index.html index d14cb2508..fa910c969 100644 --- a/docs/filesystem/index.html +++ b/docs/filesystem/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    Filesystem

    A unified filesystem abstraction across local disk, in-memory trees, SQLite, and ZIP archives. Forward-slash paths everywhere, even on Windows.

    @@ -32,77 +57,77 @@

    In-memory tree

    The fastest backend. Stores everything in PHP arrays; ideal for tests and ephemeral processing.

    Local disk

    LocalFilesystem::create($root) chroots all paths to $root. The forward-slash convention is enforced even on Windows.

    SQLite-backed

    Everything lives in a single SQLite file. Convenient for portable scratch storage that survives a process boundary.

    Walk a tree

    The interface includes a recursive walker so you can iterate every file regardless of backend.

    Full API reference: Filesystem README.

    diff --git a/docs/git/index.html b/docs/git/index.html index 087e846cd..a6e7f8880 100644 --- a/docs/git/index.html +++ b/docs/git/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    Git

    A pure-PHP Git client and server. Commits, branches, diffs, HTTP push/pull — all without shelling out to git.

    @@ -30,42 +55,42 @@

    Commit files in memory

    The repository builds the blob, tree, and commit objects for you. Backed by any Filesystem, including InMemoryFilesystem for ephemeral repos.

    Read objects by hash

    Every Git object is identified by its SHA-1. Store a blob, get the hash back, read it later.

    Full API reference: Git README.

    diff --git a/docs/html/index.html b/docs/html/index.html index 1b8f53782..0acda8c4f 100644 --- a/docs/html/index.html +++ b/docs/html/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    HTML

    A full HTML5 parser and tag processor in pure PHP, mirroring WordPress core's HTML API. No libxml2, no DOM extension, no external dependencies.

    @@ -32,56 +57,56 @@

    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.

    Query by tag and class

    Pass an array to next_tag() to find tags matching a tag name, a CSS class, or both. The processor never builds a DOM; it just advances a cursor.

    Walk the structure

    WP_HTML_Processor understands HTML5 tree construction. You can ask it for the current depth, breadcrumbs, and whether a tag is implicitly closed.

    @@ -89,18 +114,18 @@

    Decode HTML entities

    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.

    @@ -109,23 +134,23 @@

    Insert and remove subtrees

    The full processor lets you replace, insert, or remove entire subtrees by addressing a bookmark. Useful for scrubbing posts before display.

    Full API reference: HTML README.

    diff --git a/docs/httpclient/index.html b/docs/httpclient/index.html index 2802212a9..f12e50fbf 100644 --- a/docs/httpclient/index.html +++ b/docs/httpclient/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    HttpClient

    Async HTTP client without curl required. Uses sockets when curl is missing, supports concurrent requests and streaming responses.

    @@ -32,18 +57,18 @@

    GET a URL

    Build a Request, hand it to Client::fetch(), await the response, read the body.

    Full API reference: HttpClient README.

    diff --git a/docs/httpserver/index.html b/docs/httpserver/index.html index 8bd12d611..345d6f7ed 100644 --- a/docs/httpserver/index.html +++ b/docs/httpserver/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    HttpServer

    A minimal blocking TCP HTTP server in pure PHP. For CLI tools and tests, not for production traffic.

    @@ -30,24 +55,24 @@

    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.

    Full API reference: HttpServer README.

    diff --git a/docs/markdown/index.html b/docs/markdown/index.html index eb475fa12..7dffa05d0 100644 --- a/docs/markdown/index.html +++ b/docs/markdown/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    Markdown

    Bidirectional converter between Markdown and WordPress block markup. Round-trips faithfully so you can keep Markdown files and a WP database in sync.

    @@ -30,47 +55,47 @@

    Markdown to blocks

    Pass a Markdown string to MarkdownConsumer and call consume(). The result exposes block markup and any YAML frontmatter as metadata.

    Blocks back to Markdown

    MarkdownProducer walks block markup and emits matching Markdown. Round-tripping a document should produce equivalent output (modulo whitespace normalization).

    Frontmatter

    YAML frontmatter is parsed and exposed as metadata so the block markup stays clean.

    Full API reference: Markdown README.

    diff --git a/docs/merge/index.html b/docs/merge/index.html index 3aa7a7763..f160216ab 100644 --- a/docs/merge/index.html +++ b/docs/merge/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    Merge

    Three-way merge and diff. Pluggable differ + merger + optional validator.

    @@ -30,8 +55,8 @@

    Merge two branches

    Give it a base, branch A, and branch B. Get a merge result with conflicts (if any) and the merged content.

    Inspect a diff

    The Diff object is a flat list of equal/insert/delete operations.

    diff --git a/docs/polyfill/index.html b/docs/polyfill/index.html index 53459f91e..a2d7929e5 100644 --- a/docs/polyfill/index.html +++ b/docs/polyfill/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    Polyfill

    PHP 8 string functions on PHP 7.2+, WordPress hook stubs, and translation/escaping passthroughs so toolkit code runs without WordPress.

    @@ -30,27 +55,27 @@

    PHP 8 strings on 7.2

    Polyfills are loaded automatically through Composer's autoload.files. They define functions only when missing, so they're safe to use alongside PHP 8.

    WordPress hooks without WordPress

    A minimal but real implementation of add_filter, apply_filters, and the action equivalents — priorities and all.

    Full API reference: Polyfill README.

    diff --git a/docs/xml/index.html b/docs/xml/index.html index c208004dc..4707f1577 100644 --- a/docs/xml/index.html +++ b/docs/xml/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    XML

    Streaming XML processor without libxml2. Modify attributes, walk namespaces, scan large documents without loading them into memory.

    @@ -30,41 +55,41 @@

    Read and rewrite an attribute

    The XMLProcessor mirrors the HTML tag processor — find a tag, read or set attributes, get the modified document back.

    Namespaces are first-class

    Methods take a namespace URI as the first argument, never a prefix. The processor resolves prefixes itself.

    Full API reference: XML README.

    diff --git a/docs/zip/index.html b/docs/zip/index.html index d05c3bf38..c83fdd2c3 100644 --- a/docs/zip/index.html +++ b/docs/zip/index.html @@ -18,9 +18,34 @@ GitHub -
    - +

    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.

    @@ -32,8 +57,8 @@

    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.

    Read entries through a filesystem

    ZipFilesystem implements the toolkit's Filesystem interface, so you can ls(), is_file(), and get_contents() as if the archive were a directory tree.

    @@ -106,8 +131,8 @@

    Stream a large file out of an arc

    For multi-megabyte entries inside an archive, use open_read_stream() instead of loading the whole file. The decoder inflates as you pull.

    Full API reference: Zip README.

    From 0f9a8502859aaea54a57dadf320b16b837b01aa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 28 Apr 2026 20:41:40 +0200 Subject: [PATCH 3/7] Remove stray .gitignore.tmp --- .gitignore.tmp | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .gitignore.tmp diff --git a/.gitignore.tmp b/.gitignore.tmp deleted file mode 100644 index e69de29bb..000000000 From b6c77ad6f3ccf6254a2a02cb66b5e4202c1d343e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 28 Apr 2026 20:44:39 +0200 Subject: [PATCH 4/7] Sidebar: put 'On this page' above 'All components' --- bin/build-docs.py | 2 +- docs/assets/style.css | 6 +++--- docs/blockparser/index.html | 2 +- docs/blueprints/index.html | 2 +- docs/bytestream/index.html | 2 +- docs/cli/index.html | 2 +- docs/coding-standards/index.html | 2 +- docs/corsproxy/index.html | 2 +- docs/dataliberation/index.html | 2 +- docs/encoding/index.html | 2 +- docs/filesystem/index.html | 2 +- docs/git/index.html | 2 +- docs/html/index.html | 2 +- docs/httpclient/index.html | 2 +- docs/httpserver/index.html | 2 +- docs/markdown/index.html | 2 +- docs/merge/index.html | 2 +- docs/polyfill/index.html | 2 +- docs/xml/index.html | 2 +- docs/zip/index.html | 2 +- 20 files changed, 22 insertions(+), 22 deletions(-) diff --git a/bin/build-docs.py b/bin/build-docs.py index 91efd102d..57241de1a 100755 --- a/bin/build-docs.py +++ b/bin/build-docs.py @@ -75,13 +75,13 @@ def render_component(slug, title, lede, install, sections): '\t\n' ) diff --git a/docs/assets/style.css b/docs/assets/style.css index 0b72c75c6..307d13ce0 100644 --- a/docs/assets/style.css +++ b/docs/assets/style.css @@ -153,9 +153,9 @@ footer.site { } .components-nav { - margin-bottom: 1.75rem; - padding-bottom: 1.25rem; - border-bottom: 1px solid var(--border); + margin-top: 1.75rem; + padding-top: 1.25rem; + border-top: 1px solid var(--border); } .components-nav summary { diff --git a/docs/blockparser/index.html b/docs/blockparser/index.html index 2e19ad6e8..bbddfabca 100644 --- a/docs/blockparser/index.html +++ b/docs/blockparser/index.html @@ -21,6 +21,7 @@

    BlockParser

    diff --git a/docs/blueprints/index.html b/docs/blueprints/index.html index c1d0dd793..803857652 100644 --- a/docs/blueprints/index.html +++ b/docs/blueprints/index.html @@ -21,6 +21,7 @@

    Blueprints

    diff --git a/docs/bytestream/index.html b/docs/bytestream/index.html index 106af41e3..8ba01c1bf 100644 --- a/docs/bytestream/index.html +++ b/docs/bytestream/index.html @@ -21,6 +21,7 @@

    ByteStream

    diff --git a/docs/cli/index.html b/docs/cli/index.html index 6fe26784a..7189c1ae9 100644 --- a/docs/cli/index.html +++ b/docs/cli/index.html @@ -21,6 +21,7 @@

    CLI

    diff --git a/docs/coding-standards/index.html b/docs/coding-standards/index.html index 929c7dbbb..86b99a029 100644 --- a/docs/coding-standards/index.html +++ b/docs/coding-standards/index.html @@ -21,6 +21,7 @@

    ToolkitCodingStandards

    diff --git a/docs/corsproxy/index.html b/docs/corsproxy/index.html index a1a0ca4e3..e6ad36797 100644 --- a/docs/corsproxy/index.html +++ b/docs/corsproxy/index.html @@ -21,6 +21,7 @@

    CORSProxy

    diff --git a/docs/dataliberation/index.html b/docs/dataliberation/index.html index b6109bfc4..5f220cffa 100644 --- a/docs/dataliberation/index.html +++ b/docs/dataliberation/index.html @@ -21,6 +21,7 @@

    DataLiberation

    diff --git a/docs/encoding/index.html b/docs/encoding/index.html index 5a11a29b3..b6aa09fee 100644 --- a/docs/encoding/index.html +++ b/docs/encoding/index.html @@ -21,6 +21,7 @@

    Encoding

    diff --git a/docs/filesystem/index.html b/docs/filesystem/index.html index fa910c969..ed5605a8c 100644 --- a/docs/filesystem/index.html +++ b/docs/filesystem/index.html @@ -21,6 +21,7 @@

    Filesystem

    diff --git a/docs/git/index.html b/docs/git/index.html index a6e7f8880..6c59fc414 100644 --- a/docs/git/index.html +++ b/docs/git/index.html @@ -21,6 +21,7 @@

    Git

    diff --git a/docs/html/index.html b/docs/html/index.html index 0acda8c4f..6df000030 100644 --- a/docs/html/index.html +++ b/docs/html/index.html @@ -21,6 +21,7 @@

    HTML

    diff --git a/docs/httpclient/index.html b/docs/httpclient/index.html index f12e50fbf..3adb82fec 100644 --- a/docs/httpclient/index.html +++ b/docs/httpclient/index.html @@ -21,6 +21,7 @@

    HttpClient

    diff --git a/docs/httpserver/index.html b/docs/httpserver/index.html index 345d6f7ed..ffe6aff92 100644 --- a/docs/httpserver/index.html +++ b/docs/httpserver/index.html @@ -21,6 +21,7 @@

    HttpServer

    diff --git a/docs/markdown/index.html b/docs/markdown/index.html index 7dffa05d0..5b029e006 100644 --- a/docs/markdown/index.html +++ b/docs/markdown/index.html @@ -21,6 +21,7 @@

    Markdown

    diff --git a/docs/merge/index.html b/docs/merge/index.html index f160216ab..ced2b77ef 100644 --- a/docs/merge/index.html +++ b/docs/merge/index.html @@ -21,6 +21,7 @@

    Merge

    diff --git a/docs/polyfill/index.html b/docs/polyfill/index.html index a2d7929e5..d30406111 100644 --- a/docs/polyfill/index.html +++ b/docs/polyfill/index.html @@ -21,6 +21,7 @@

    Polyfill

    diff --git a/docs/xml/index.html b/docs/xml/index.html index 4707f1577..217283ca1 100644 --- a/docs/xml/index.html +++ b/docs/xml/index.html @@ -21,6 +21,7 @@

    XML

    diff --git a/docs/zip/index.html b/docs/zip/index.html index c83fdd2c3..8d6745551 100644 --- a/docs/zip/index.html +++ b/docs/zip/index.html @@ -21,6 +21,7 @@

    Zip

    From c444300a1bc29c7dcafd03bf658979f25f4d606b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 28 Apr 2026 21:06:49 +0200 Subject: [PATCH 5/7] Expand every component page with deeper, real-world examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each page now ships 5–10 examples ranked simple to complex, drawn from real problems people hit when reaching for these libraries: lazy-loading images and stamping CSP nonces (HTML), building EPUBs and defending against zip-slip (Zip), three-way merging a Markdown folder against a WP database (Merge), parallel HTTP fan-out (HttpClient), syncing an Obsidian vault into WordPress posts (Markdown + DataLiberation), and so on. Real composition between components — Zip into InMemoryFilesystem, Markdown into WXR — shows how the toolkit is designed to compose. Content moved into bin/_docs_components.py so the generator stays small and the catalog is one focused diff to extend. --- bin/_docs_components.py | 2032 ++++++++++++++++++++++++++++++ bin/build-docs.py | 756 +---------- docs/blockparser/index.html | 187 ++- docs/blueprints/index.html | 34 +- docs/bytestream/index.html | 132 +- docs/cli/index.html | 146 ++- docs/coding-standards/index.html | 33 +- docs/corsproxy/index.html | 117 +- docs/dataliberation/index.html | 191 ++- docs/encoding/index.html | 97 +- docs/filesystem/index.html | 176 ++- docs/git/index.html | 166 ++- docs/html/index.html | 244 +++- docs/httpclient/index.html | 129 +- docs/httpserver/index.html | 86 +- docs/index.html | 18 +- docs/markdown/index.html | 110 +- docs/merge/index.html | 118 +- docs/polyfill/index.html | 102 +- docs/xml/index.html | 126 +- docs/zip/index.html | 247 +++- 21 files changed, 4124 insertions(+), 1123 deletions(-) create mode 100644 bin/_docs_components.py diff --git a/bin/_docs_components.py b/bin/_docs_components.py new file mode 100644 index 000000000..5e273eb2e --- /dev/null +++ b/bin/_docs_components.py @@ -0,0 +1,2032 @@ +# Component catalog for the runnable docs site. Imported by bin/build-docs.py. +# +# Format: list of (slug, lede_html, sections), where sections is a list of +# (heading, body_html, snippet_or_None) +# and snippet is (filename, php_code). +# +# Both body_html and php_code may use HTML entities (< > & " ') +# — the renderer in build-docs.py decodes them before output. That keeps the +# embedded snippets readable when this file is edited as Python. + +LOAD = "require '/wordpress/wp-content/php-toolkit/vendor/autoload.php';\n\n" + + +def php(snippet): + return 'libxml2, DOMDocument, or regex hacks — and rewrite attributes in a single linear pass.', + 'wp-php-toolkit/html', + [ + ('Two layers, one mental model', + '

    The component gives you two processors. WP_HTML_Tag_Processor is a forward-only cursor over tags and tokens — perfect for attribute rewriting at scale. WP_HTML_Processor layers full HTML5 tree construction on top so you can query by ancestry (breadcrumbs), serialize back to well-formed HTML, and trust that <p>one<p>two parses as two paragraphs the way a browser sees it.

    ' + '

    Footgun: mutations are buffered. Nothing changes in the source string until you call get_updated_html(). If you read get_attribute() after a set_attribute() on the same tag, you see the new value — but downstream tooling reading the original string sees stale HTML until you serialize.

    ', + None), + ('Add loading="lazy" to every image', + '

    The "hello world" of tag rewriting. One linear pass, no DOM, no reserialization cost beyond the bytes you actually changed.

    ', + ('lazy-load-images.php', php('''$html = '
    +\tHero +\t

    Intro copy.

    +\tInline +
    '; + +$tags = new WP_HTML_Tag_Processor( $html ); +while ( $tags->next_tag( 'img' ) ) { +\t// Don't clobber an explicit eager hint the author already set. +\tif ( null === $tags->get_attribute( 'loading' ) ) { +\t\t$tags->set_attribute( 'loading', 'lazy' ); +\t} +\t$tags->set_attribute( 'decoding', 'async' ); +} + +echo $tags->get_updated_html();'''))), + ('Rewrite relative links to absolute URLs', + '

    Useful when rendering content for an RSS feed, an email, or a site behind a CDN where relative paths break. The processor only rewrites the bytes that changed, so untouched markup stays byte-identical.

    ', + ('absolute-links.php', php('''$html = '

    See about, x, ' +\t. 'and contact.

    '; + +$base = 'https://my-site.test/'; + +$tags = new WP_HTML_Tag_Processor( $html ); +while ( $tags->next_tag( 'a' ) ) { +\t$href = $tags->get_attribute( 'href' ); +\tif ( null === $href || '' === $href ) { +\t\tcontinue; +\t} +\tif ( preg_match( '#^[a-z][a-z0-9+.-]*:#i', $href ) || 0 === strpos( $href, '//' ) || 0 === strpos( $href, '#' ) ) { +\t\tcontinue; +\t} +\t$tags->set_attribute( 'href', rtrim( $base, '/' ) . '/' . ltrim( $href, '/' ) ); +} + +echo $tags->get_updated_html();'''))), + ('Strip every script and inline event handler', + '

    A common sanitization step: neutralize untrusted HTML before display. Blank a script\'s body with set_modifiable_text() and strip every on* attribute via get_attribute_names_with_prefix().

    ', + ('sanitize-html.php', php('''$untrusted = '

    Hi friend!

    ' +\t. '' +\t. ''; + +$tags = new WP_HTML_Tag_Processor( $untrusted ); +while ( $tags->next_tag() ) { +\tif ( 'SCRIPT' === $tags->get_tag() && ! $tags->is_tag_closer() ) { +\t\t$tags->set_modifiable_text( '' ); +\t} +\t$on_handlers = $tags->get_attribute_names_with_prefix( 'on' ); +\tif ( $on_handlers ) { +\t\tforeach ( $on_handlers as $name ) { +\t\t\t$tags->remove_attribute( $name ); +\t\t} +\t} +} + +echo $tags->get_updated_html();'''))), + ('Stamp a CSP nonce on inline scripts and styles', + '

    Content Security Policy in nonce- mode requires every inline <script> and <style> to carry a matching nonce attribute. Tag-by-tag is exactly the right granularity.

    ', + ('csp-nonce.php', php('''$nonce = bin2hex( random_bytes( 8 ) ); + +$html = '' +\t. ''; + +$tags = new WP_HTML_Tag_Processor( $html ); +while ( $tags->next_tag() ) { +\t$tag = $tags->get_tag(); +\tif ( ( 'SCRIPT' === $tag || 'STYLE' === $tag ) && ! $tags->is_tag_closer() ) { +\t\t$tags->set_attribute( 'nonce', $nonce ); +\t} +} + +echo "nonce: {$nonce}\\n\\n"; +echo $tags->get_updated_html();'''))), + ('Build a srcset from a single src', + '

    Generate responsive image markup at render time without touching the editor data model. Read the existing src, derive a srcset with width descriptors, add a sizes hint.

    ', + ('srcset-rewrite.php', php('''$html = '
    Sunset
    '; +$widths = array( 480, 768, 1200 ); + +$tags = new WP_HTML_Tag_Processor( $html ); +while ( $tags->next_tag( 'img' ) ) { +\t$src = $tags->get_attribute( 'src' ); +\tif ( null === $src || $tags->get_attribute( 'srcset' ) !== null ) { +\t\tcontinue; +\t} +\t$variants = array(); +\tforeach ( $widths as $w ) { +\t\t$variants[] = $src . '?w=' . $w . ' ' . $w . 'w'; +\t} +\t$tags->set_attribute( 'srcset', implode( ', ', $variants ) ); +\t$tags->set_attribute( 'sizes', '(max-width: 768px) 100vw, 768px' ); +} + +echo $tags->get_updated_html();'''))), + ('Decode HTML entities the way the spec demands', + '

    The HTML5 entity table has roughly 2,200 named references and a long list of edge cases. WP_HTML_Decoder implements the algorithm — don\'t roll your own.

    ', + ('decode-entities.php', php('''echo "attribute: " . WP_HTML_Decoder::decode_attribute( 'path?a=1&b=2&copy' ) . "\\n"; +echo "text: " . WP_HTML_Decoder::decode_text_node( 'AT&T — 100% 😀' ) . "\\n"; + +// Safe URL prefix check that respects encoded colons (a classic XSS vector). +$is_javascript = WP_HTML_Decoder::attribute_starts_with( +\t'java script:alert(1)', +\t'javascript:', +\t'ascii-case-insensitive' +); +var_dump( $is_javascript );'''))), + ('Find images by ancestry with breadcrumbs', + '

    The full WP_HTML_Processor understands HTML5 tree construction, so you can ask "find every <img> directly inside a <figure>" without writing your own DOM walker.

    ', + ('breadcrumbs.php', php('''$html = '
    ' +\t. '
    Hero
    Hero shot
    ' +\t. '

    Body copy mid-paragraph.

    ' +\t. '
    Diagram
    ' +\t. '
    '; + +$p = WP_HTML_Processor::create_fragment( $html ); +$figure_images = 0; +while ( $p->next_tag( array( 'breadcrumbs' => array( 'FIGURE', 'IMG' ) ) ) ) { +\t$p->add_class( 'figure-image' ); +\t$figure_images++; +} + +echo "found {$figure_images} figure images\\n"; +echo $p->get_updated_html();'''))), + ('Outline a document by walking tokens with depth', + '

    The full processor exposes get_current_depth() and get_breadcrumbs(). Combine with next_token() to print a structural outline.

    ', + ('outline.php', php('''$html = '

    Title

    ' +\t. '

    Chapter 1

    Body

    ' +\t. '

    Chapter 2

    More body

    ' +\t. '
    '; + +$p = WP_HTML_Processor::create_fragment( $html ); +while ( $p->next_token() ) { +\tif ( '#tag' !== $p->get_token_type() || $p->is_tag_closer() ) { +\t\tcontinue; +\t} +\t$tag = $p->get_tag(); +\tif ( ! preg_match( '/^H[1-6]$/', $tag ) ) { +\t\tcontinue; +\t} +\t$indent = str_repeat( ' ', max( 0, $p->get_current_depth() - 2 ) ); +\t$text = ''; +\twhile ( $p->next_token() ) { +\t\tif ( '#text' === $p->get_token_type() ) { +\t\t\t$text .= $p->get_modifiable_text(); +\t\t\tcontinue; +\t\t} +\t\tif ( '#tag' === $p->get_token_type() && $tag === $p->get_tag() && $p->is_tag_closer() ) { +\t\t\tbreak; +\t\t} +\t} +\techo "{$indent}{$tag} {$text}\\n"; +}'''))), + ('Bookmarks: annotate a parent based on its children', + '

    Bookmarks are the one escape from forward-only scanning. Save a position, scan ahead, decide what to do, then seek() back and rewrite the earlier tag.

    ', + ('bookmarks.php', php('''$html = '
      ' +\t. '
    • Buy milk
    • ' +\t. '
    • Walk the dog
    • ' +\t. '
    • Read book
    • ' +\t. '
    '; + +$tags = new WP_HTML_Tag_Processor( $html ); +$tags->next_tag( 'ul' ); +$tags->set_bookmark( 'list' ); + +$total = 0; +$done = 0; +while ( $tags->next_tag( 'input' ) ) { +\t$total++; +\tif ( null !== $tags->get_attribute( 'checked' ) ) { +\t\t$done++; +\t} +} + +$tags->seek( 'list' ); +$tags->set_attribute( 'data-progress', $done . '/' . $total ); +$tags->release_bookmark( 'list' ); + +echo $tags->get_updated_html();'''))), + ])) + +# =========================================================================== +# Zip +# =========================================================================== +COMPONENTS.append(('zip', 'Zip', + 'Read and write ZIP archives in pure PHP — no libzip, no ZipArchive. Streams entries one at a time, so you can build EPUBs, .docx files, and multi-gigabyte plugin bundles without buffering the archive in memory.', + 'wp-php-toolkit/zip', + [ + ('Why this exists', + '

    The PHP ecosystem has two ZIP options: the ZipArchive extension (often missing on shared hosts and stripped from WebAssembly builds) and shelling out to zip. Neither helps you stream a 4 GB plugin bundle to the browser, peek at an EPUB manifest without unpacking it, or build a .docx on a host without libzip.

    ' + '

    The Zip component reads and writes Stored and Deflate archives in pure PHP. The decoder is pull-based, so listing the central directory of a 2 GB ZIP costs roughly the size of the directory itself. The encoder accepts any ByteWriteStream as a sink and writes one entry at a time.

    ', + None), + ('Read a file out of a ZIP', + '

    ZipFilesystem implements the standard Filesystem interface, so once you wrap the byte reader you can call get_contents(), ls(), is_dir() just like local disk.

    ', + ('teaser-read.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\FileReadStream; +use WordPress\\ByteStream\\WriteStream\\FileWriteStream; +use WordPress\\Zip\\FileEntry; +use WordPress\\Zip\\ZipDecoder; +use WordPress\\Zip\\ZipEncoder; +use WordPress\\Zip\\ZipFilesystem; + +$path = tempnam( sys_get_temp_dir(), 'demo' ) . '.zip'; +$out = FileWriteStream::from_path( $path, 'truncate' ); +$enc = new ZipEncoder( $out ); +$enc->append_file( new FileEntry( array( +\t'path' => 'readme.txt', +\t'compression_method' => ZipDecoder::COMPRESSION_NONE, +\t'body_reader' => new MemoryPipe( 'Hello from inside the zip.' ), +) ) ); +$enc->close(); +$out->close_writing(); + +$zip = ZipFilesystem::create( FileReadStream::from_path( $path ) ); +echo $zip->get_contents( 'readme.txt' );'''))), + ('Build an EPUB from scratch', + '

    An EPUB is a ZIP with one strict rule: the mimetype entry must come first and must be Stored. Everything else can be Deflate.

    ' + '

    Gotcha: e-readers reject EPUBs whose mimetype entry has compression. Use COMPRESSION_NONE for that single entry.

    ', + ('epub.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\FileReadStream; +use WordPress\\ByteStream\\WriteStream\\FileWriteStream; +use WordPress\\Zip\\FileEntry; +use WordPress\\Zip\\ZipDecoder; +use WordPress\\Zip\\ZipEncoder; +use WordPress\\Zip\\ZipFilesystem; + +$path = tempnam( sys_get_temp_dir(), 'book' ) . '.epub'; +$out = FileWriteStream::from_path( $path, 'truncate' ); +$enc = new ZipEncoder( $out ); + +// 1) The mimetype entry MUST be first and stored uncompressed. +$enc->append_file( new FileEntry( array( +\t'path' => 'mimetype', +\t'compression_method' => ZipDecoder::COMPRESSION_NONE, +\t'body_reader' => new MemoryPipe( 'application/epub+zip' ), +) ) ); + +$container = '' +\t. '' +\t. '' +\t. ''; + +foreach ( array( +\t'META-INF/container.xml' => $container, +\t'EPUB/package.opf' => '', +\t'EPUB/chapter1.xhtml' => '

    Chapter 1

    It was a dark and stormy night.

    ', +) as $name => $body ) { +\t$enc->append_file( new FileEntry( array( +\t\t'path' => $name, +\t\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, +\t\t'body_reader' => new MemoryPipe( $body ), +\t) ) ); +} +$enc->close(); +$out->close_writing(); + +$zip = ZipFilesystem::create( FileReadStream::from_path( $path ) ); +printf( "mimetype: %s\\n", $zip->get_contents( 'mimetype' ) ); +printf( "size on disk: %d bytes\\n", filesize( $path ) );'''))), + ('Stream a large entry without buffering it', + '

    Calling get_contents() on a 500 MB CSV inside a ZIP would eat 500 MB of RAM. Use open_read_stream() instead and inflate-as-you-go.

    ' + '

    Gotcha: only one entry stream open at a time. Drain or finish the previous stream before opening the next.

    ', + ('stream-large.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\FileReadStream; +use WordPress\\ByteStream\\WriteStream\\FileWriteStream; +use WordPress\\Zip\\FileEntry; +use WordPress\\Zip\\ZipDecoder; +use WordPress\\Zip\\ZipEncoder; +use WordPress\\Zip\\ZipFilesystem; + +$path = tempnam( sys_get_temp_dir(), 'big' ) . '.zip'; +$out = FileWriteStream::from_path( $path, 'truncate' ); +$enc = new ZipEncoder( $out ); +$enc->append_file( new FileEntry( array( +\t'path' => 'data.csv', +\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, +\t'body_reader' => new MemoryPipe( str_repeat( "id,value,timestamp\\n1,foo,2024\\n2,bar,2024\\n", 5000 ) ), +) ) ); +$enc->close(); +$out->close_writing(); + +$zip = ZipFilesystem::create( FileReadStream::from_path( $path ) ); +$stream = $zip->open_read_stream( 'data.csv' ); + +$rows = 0; +$bytes = 0; +$tail = ''; +while ( ! $stream->reached_end_of_data() ) { +\t$n = $stream->pull( 8192 ); +\tif ( 0 === $n ) break; +\t$chunk = $tail . $stream->consume( $n ); +\t$lines = explode( "\\n", $chunk ); +\t$tail = array_pop( $lines ); +\t$rows += count( $lines ); +\t$bytes += $n; +} +printf( "Inflated %d bytes in 8 KB chunks, parsed %d rows.\\n", $bytes, $rows );'''))), + ('Repack: modify one file, copy the rest', + '

    Updating one file in a ZIP without rewriting the others is impossible at the format level — the central directory points at byte offsets. The pragmatic answer is repack: stream the source archive into a new one, swapping the file you care about.

    ', + ('repack.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\FileReadStream; +use WordPress\\ByteStream\\WriteStream\\FileWriteStream; +use WordPress\\Zip\\FileEntry; +use WordPress\\Zip\\ZipDecoder; +use WordPress\\Zip\\ZipEncoder; +use WordPress\\Zip\\ZipFilesystem; + +$src_path = tempnam( sys_get_temp_dir(), 'orig' ) . '.zip'; +$src_out = FileWriteStream::from_path( $src_path, 'truncate' ); +$src_enc = new ZipEncoder( $src_out ); +foreach ( array( +\t'config.json' => '{"debug":false,"version":"1.0"}', +\t'app/index.php' => ' 'body{color:#333}', +) as $name => $body ) { +\t$src_enc->append_file( new FileEntry( array( +\t\t'path' => $name, +\t\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, +\t\t'body_reader' => new MemoryPipe( $body ), +\t) ) ); +} +$src_enc->close(); +$src_out->close_writing(); + +$source = ZipFilesystem::create( FileReadStream::from_path( $src_path ) ); +$dst_path = tempnam( sys_get_temp_dir(), 'repacked' ) . '.zip'; +$dst_out = FileWriteStream::from_path( $dst_path, 'truncate' ); +$dst_enc = new ZipEncoder( $dst_out ); + +$walk = function ( $dir ) use ( &$walk, $source, $dst_enc ) { +\tforeach ( $source->ls( $dir ) as $name ) { +\t\t$path = rtrim( $dir, '/' ) . '/' . $name; +\t\tif ( $source->is_dir( $path ) ) { +\t\t\t$walk( $path ); +\t\t\tcontinue; +\t\t} +\t\t$rel = ltrim( $path, '/' ); +\t\t$body = ( 'config.json' === $rel ) +\t\t\t? '{"debug":true,"version":"1.0.1"}' +\t\t\t: $source->get_contents( $rel ); +\t\t$dst_enc->append_file( new FileEntry( array( +\t\t\t'path' => $rel, +\t\t\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, +\t\t\t'body_reader' => new MemoryPipe( $body ), +\t\t) ) ); +\t} +}; +$walk( '/' ); +$dst_enc->close(); +$dst_out->close_writing(); + +$repacked = ZipFilesystem::create( FileReadStream::from_path( $dst_path ) ); +echo "new config.json: " . $repacked->get_contents( 'config.json' ) . "\\n"; +echo "untouched: " . $repacked->get_contents( 'app/index.php' ) . "\\n";'''))), + ('Defend against zip-slip', + '

    A malicious archive can name an entry ../../etc/passwd and trick a naive extractor into clobbering files outside the destination. ZipDecoder::sanitize_path() strips leading ../ segments and collapses internal /../ sequences before exposing the path.

    ', + ('zip-slip.php', php('''use WordPress\\Zip\\ZipDecoder; + +$evil_inputs = array( +\t'../../etc/passwd', +\t'./safe/path.txt', +\t'a/../../b/secret', +\t'a//b///c.txt', +\t'../../../../root/.ssh/authorized_keys', +); +foreach ( $evil_inputs as $name ) { +\tprintf( "%-45s => %s\\n", $name, ZipDecoder::sanitize_path( $name ) ); +}'''))), + ('Pipe ZIP entries into an InMemoryFilesystem', + '

    Real-world recipe: take an uploaded plugin ZIP, expand it into an InMemoryFilesystem so you can validate, edit, or scan it before it ever touches disk. Three components compose into something you couldn\'t build with ZipArchive alone.

    ', + ('zip-to-memfs.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\FileReadStream; +use WordPress\\ByteStream\\WriteStream\\FileWriteStream; +use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Zip\\FileEntry; +use WordPress\\Zip\\ZipDecoder; +use WordPress\\Zip\\ZipEncoder; +use WordPress\\Zip\\ZipFilesystem; +use function WordPress\\Filesystem\\copy_between_filesystems; + +$path = tempnam( sys_get_temp_dir(), 'app' ) . '.zip'; +$out = FileWriteStream::from_path( $path, 'truncate' ); +$enc = new ZipEncoder( $out ); +foreach ( array( +\t'app/index.php' => ' ' 'body{margin:0}', +\t'app/README.md' => '# App', +) as $name => $body ) { +\t$enc->append_file( new FileEntry( array( +\t\t'path' => $name, +\t\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, +\t\t'body_reader' => new MemoryPipe( $body ), +\t) ) ); +} +$enc->close(); +$out->close_writing(); + +$zip = ZipFilesystem::create( FileReadStream::from_path( $path ) ); +$mem = InMemoryFilesystem::create(); +copy_between_filesystems( array( +\t'source_filesystem' => $zip, +\t'source_path' => '/', +\t'target_filesystem' => $mem, +\t'target_path' => '/', +) ); + +$mem->put_contents( '/app/VERSION', '1.0.0' ); +echo "files now in memory:\\n"; +$walk = function ( $dir ) use ( &$walk, $mem ) { +\tforeach ( $mem->ls( $dir ) as $name ) { +\t\t$p = rtrim( $dir, '/' ) . '/' . $name; +\t\t$mem->is_dir( $p ) ? $walk( $p ) : print( " " . $p . "\\n" ); +\t} +}; +$walk( '/' );'''))), + ])) + +# =========================================================================== +# ByteStream +# =========================================================================== +COMPONENTS.append(('bytestream', 'ByteStream', + 'Composable streaming primitives for reading, writing, transforming, hashing, and compressing byte data. Pull/peek/consume semantics let parsers backtrack without copying, and deflate, inflate, and checksum filters snap together like Lego.', + 'wp-php-toolkit/bytestream', + [ + ('Why this exists', + '

    PHP\'s native streams are powerful but inconsistent. fread on a socket may return short reads with no warning; stream_filter_append is awkward to compose; gzopen works only on files. The ByteStream component normalizes all of these behind one tiny interface — pull / peek / consume — so a parser, a hash function, and a deflate filter all see the same shape.

    ' + '

    The split between pull (buffer up to N bytes) and consume (advance past N bytes) is the secret. Parsers can peek ahead to detect a record boundary and decide whether to consume, without copying or allocating.

    ', + None), + ('Read a file in chunks', + '

    The canonical loop. pull() tells you how many bytes are buffered; consume() reads them and advances. The buffer never grows beyond the chunk size you ask for.

    ', + ('teaser-read.php', php('''use WordPress\\ByteStream\\ReadStream\\FileReadStream; + +$path = tempnam( sys_get_temp_dir(), 'demo' ); +file_put_contents( $path, str_repeat( "log line\\n", 200 ) ); + +$reader = FileReadStream::from_path( $path ); +$total = 0; +while ( ! $reader->reached_end_of_data() ) { +\t$n = $reader->pull( 256 ); +\tif ( 0 === $n ) break; +\t$total += strlen( $reader->consume( $n ) ); +} +$reader->close_reading(); +echo "Read {$total} bytes in 256-byte chunks.\\n";'''))), + ('MemoryPipe as write-then-read buffer', + '

    MemoryPipe is bidirectional: you append_bytes() as a writer and pull/consume as a reader. Easiest way to wire one component\'s output into another\'s input.

    ' + '

    Gotcha: a producer must call close_writing() when done — otherwise the consumer eventually throws NotEnoughDataException instead of seeing EOF.

    ', + ('memory-pipe.php', php('''use WordPress\\ByteStream\\MemoryPipe; + +$pipe = new MemoryPipe(); +$pipe->append_bytes( "first chunk\\n" ); +$pipe->append_bytes( "second chunk\\n" ); +$pipe->append_bytes( "third chunk\\n" ); +$pipe->close_writing(); + +while ( ! $pipe->reached_end_of_data() ) { +\t$n = $pipe->pull( 1024 ); +\tif ( 0 === $n ) break; +\techo "got: " . $pipe->consume( $n ); +}'''))), + ('Compress on the way in, decompress on the way out', + '

    Wrap a stream in DeflateReadStream to get compressed bytes out; wrap it in InflateReadStream to get decompressed bytes out. Both are full ByteReadStream implementations, so they nest into anything else that takes a stream.

    ', + ('deflate-roundtrip.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\DeflateReadStream; +use WordPress\\ByteStream\\ReadStream\\InflateReadStream; + +$original = str_repeat( "the quick brown fox. ", 50 ); + +$src = new MemoryPipe( $original ); +$src->close_writing(); +$deflated = new DeflateReadStream( $src, ZLIB_ENCODING_DEFLATE ); +$compressed = $deflated->consume_all(); + +$src2 = new MemoryPipe( $compressed ); +$src2->close_writing(); +$inflated = new InflateReadStream( $src2, ZLIB_ENCODING_DEFLATE ); +$round = $inflated->consume_all(); + +printf( "original : %d bytes\\n", strlen( $original ) ); +printf( "deflated : %d bytes (%.1f%%)\\n", strlen( $compressed ), 100 * strlen( $compressed ) / strlen( $original ) ); +printf( "round-trip: %s\\n", $round === $original ? 'OK' : 'BROKEN' );'''))), + ('Line-by-line reads from a chunked source', + '

    Reading text by line means handling chunk boundaries that fall mid-line. Keep the trailing partial line and prepend it to the next pull. The rest of the loop pretends the data was always whole.

    ', + ('lines.php', php('''use WordPress\\ByteStream\\MemoryPipe; + +$pipe = new MemoryPipe(); +$pipe->append_bytes( "alpha\\nbravo\\ncharl" ); +$pipe->append_bytes( "ie\\ndelta\\necho\\n" ); +$pipe->close_writing(); + +$tail = ''; +$count = 0; +while ( ! $pipe->reached_end_of_data() ) { +\t$n = $pipe->pull( 8 ); +\tif ( 0 === $n ) break; +\t$buf = $tail . $pipe->consume( $n ); +\t$lines = explode( "\\n", $buf ); +\t$tail = array_pop( $lines ); +\tforeach ( $lines as $line ) { +\t\tprintf( "[%d] %s\\n", ++$count, $line ); +\t} +} +if ( '' !== $tail ) { +\tprintf( "[%d] %s\\n", ++$count, $tail ); +}'''))), + ('Limit a stream to a fixed window', + '

    LimitedByteReadStream exposes only the next N bytes of an underlying stream as if those were the entire stream. This is how the ZIP decoder hands you the body of one entry without letting you read into the next.

    ', + ('limited.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\ByteStream\\ReadStream\\LimitedByteReadStream; + +$source = new MemoryPipe( "HEADER:42|BODY:hello there|FOOTER:done" ); +$source->close_writing(); + +$source->pull( 10 ); +$source->consume( 10 ); + +$body = new LimitedByteReadStream( $source, 16 ); +echo "body sees: " . $body->consume_all() . "\\n"; +echo "remaining in source: " . $source->consume_all() . "\\n";'''))), + ])) + +# =========================================================================== +# Filesystem +# =========================================================================== +COMPONENTS.append(('filesystem', 'Filesystem', + 'One Filesystem interface across local disk, in-memory trees, SQLite databases, and ZIP archives. Forward-slash paths everywhere — even on Windows — so the same code runs in tests, in production, and inside read-only ZIPs.', + 'wp-php-toolkit/filesystem', + [ + ('Why this exists', + '

    Code that touches the filesystem is hard to test, hard to port to Windows, and impossible to point at non-disk storage without rewriting it. Swap LocalFilesystem for InMemoryFilesystem in tests and your suite stops touching /tmp; swap it for SQLiteFilesystem and your "files" become rows in a portable database; swap it for ZipFilesystem and you can read inside an archive with the same calls.

    ' + '

    Every backend uses forward slashes regardless of host OS. No DIRECTORY_SEPARATOR juggling, no Windows-only test failures, no surprises when a path moves between backends.

    ', + None), + ('In-memory tree', + '

    The fastest backend. No disk I/O, no cleanup, no test-isolation problems.

    ', + ('teaser-memory.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; + +$fs = InMemoryFilesystem::create(); +$fs->put_contents( '/hello.txt', 'Hello, world!' ); +echo $fs->get_contents( '/hello.txt' );'''))), + ('Test code without touching disk', + '

    Pass production code a Filesystem instead of using file_get_contents directly, and your tests run against an in-memory tree with no setup or teardown.

    ', + ('test-without-disk.php', php('''use WordPress\\Filesystem\\Filesystem; +use WordPress\\Filesystem\\InMemoryFilesystem; + +function bump_version( Filesystem $fs, $path ) { +\t$json = json_decode( $fs->get_contents( $path ), true ); +\tlist( $maj, $min, $patch ) = explode( '.', $json['version'] ); +\t$json['version'] = $maj . '.' . $min . '.' . ( (int) $patch + 1 ); +\t$fs->put_contents( $path, json_encode( $json ) ); +} + +$fs = InMemoryFilesystem::create(); +$fs->put_contents( '/package.json', '{"version":"1.2.3"}' ); +bump_version( $fs, '/package.json' ); + +echo $fs->get_contents( '/package.json' ) . "\\n";'''))), + ('Local disk with a chrooted root', + '

    LocalFilesystem::create($root) is implicitly chrooted: every path resolves relative to $root and a ../ can\'t escape. Useful when the path comes from user input.

    ', + ('local-chroot.php', php('''use WordPress\\Filesystem\\LocalFilesystem; + +$root = sys_get_temp_dir() . '/toolkit-' . uniqid(); +$fs = LocalFilesystem::create( $root ); + +$fs->mkdir( '/uploads', array( 'recursive' => true ) ); +$fs->put_contents( '/uploads/note.txt', 'Hi from local disk.' ); + +echo $fs->get_contents( '/uploads/../uploads/note.txt' ) . "\\n"; + +$fs->rmdir( '/', array( 'recursive' => true ) ); +echo "exists after cleanup? " . ( is_dir( $root ) ? 'yes' : 'no' ) . "\\n";'''))), + ('SQLite as a portable file store', + '

    The whole tree lives in one SQLite file you can ship anywhere PHP runs. Useful for plugins that want self-contained scratch storage that survives process boundaries without leaving loose files behind.

    ', + ('sqlite.php', php('''use WordPress\\Filesystem\\SQLiteFilesystem; + +$fs = SQLiteFilesystem::create( ':memory:' ); +$fs->mkdir( '/posts', array( 'recursive' => true ) ); +for ( $i = 1; $i <= 3; $i++ ) { +\t$fs->put_contents( "/posts/post-{$i}.md", "# Post {$i}\\n\\nBody {$i}." ); +} + +foreach ( $fs->ls( '/posts' ) as $name ) { +\t$first = strtok( $fs->get_contents( '/posts/' . $name ), "\\n" ); +\techo "{$name}: {$first}\\n"; +}'''))), + ('Copy a tree across backends', + '

    The killer composability move: copy_between_filesystems() streams files chunk-by-chunk from any source to any target. Pull a ZIP into SQLite, snapshot SQLite to disk, mirror disk into RAM — all the same call.

    ', + ('cross-backend-copy.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Filesystem\\LocalFilesystem; +use WordPress\\Filesystem\\SQLiteFilesystem; +use function WordPress\\Filesystem\\copy_between_filesystems; + +$root = sys_get_temp_dir() . '/copytree-' . uniqid(); +$local = LocalFilesystem::create( $root ); +$local->mkdir( '/site/posts', array( 'recursive' => true ) ); +$local->put_contents( '/site/posts/2024-01.md', '# Hello 2024' ); +$local->put_contents( '/site/index.html', '

    Home

    ' ); + +$sqlite = SQLiteFilesystem::create( ':memory:' ); +copy_between_filesystems( array( +\t'source_filesystem' => $local, +\t'source_path' => '/site', +\t'target_filesystem' => $sqlite, +\t'target_path' => '/snapshot', +) ); + +$mem = InMemoryFilesystem::create(); +copy_between_filesystems( array( +\t'source_filesystem' => $sqlite, +\t'source_path' => '/snapshot', +\t'target_filesystem' => $mem, +\t'target_path' => '/copy', +) ); + +echo "in memory after two copies:\\n"; +echo " posts: " . implode( ', ', $mem->ls( '/copy/posts' ) ) . "\\n"; +echo " index: " . $mem->get_contents( '/copy/index.html' ) . "\\n"; + +$local->rmdir( '/', array( 'recursive' => true ) );'''))), + ('Atomic write via tempfile rename', + '

    Write to a sibling tempfile, then rename — that\'s how you avoid leaving a half-written file on crash. rename() is atomic within a single filesystem.

    ', + ('atomic-write.php', php('''use WordPress\\Filesystem\\Filesystem; +use WordPress\\Filesystem\\LocalFilesystem; + +function atomic_put_contents( Filesystem $fs, $path, $bytes ) { +\t$tmp = $path . '.tmp.' . bin2hex( random_bytes( 4 ) ); +\t$fs->put_contents( $tmp, $bytes ); +\t$fs->rename( $tmp, $path ); +} + +$root = sys_get_temp_dir() . '/atomic-' . uniqid(); +$fs = LocalFilesystem::create( $root ); + +$fs->put_contents( '/config.json', '{"v":1}' ); +atomic_put_contents( $fs, '/config.json', '{"v":2}' ); + +echo "config: " . $fs->get_contents( '/config.json' ) . "\\n"; +echo "no .tmp leftovers: " . count( $fs->ls( '/' ) ) . " entries in root\\n"; + +$fs->rmdir( '/', array( 'recursive' => true ) );'''))), + ('Path helpers that behave the same on Windows', + '

    Unix path semantics on every host OS. Useful when the path is something abstract — a key in a SQLite filesystem, an entry name in a ZIP — that doesn\'t live on a real drive.

    ', + ('path-helpers.php', php('''use function WordPress\\Filesystem\\wp_join_unix_paths; +use function WordPress\\Filesystem\\wp_unix_dirname; +use function WordPress\\Filesystem\\wp_unix_path_resolve_dots; + +echo wp_join_unix_paths( '/var/www', '/site/', '/index.php' ) . "\\n"; +echo wp_unix_dirname( '/a/b/c/d.txt', 2 ) . "\\n"; +echo wp_unix_path_resolve_dots( '/a/b/../c/./d/../e' ) . "\\n";'''))), + ])) + +# =========================================================================== +# BlockParser +# =========================================================================== +COMPONENTS.append(('blockparser', 'BlockParser', + 'WordPress core\'s block parser, packaged as a standalone library. Turn block markup into a structured tree, lint posts for common authoring mistakes, and migrate attributes between block versions — all without booting WordPress.', + 'wp-php-toolkit/blockparser', + [ + ('What you get back', + '

    WP_Block_Parser::parse() returns an array of blocks. Each block is an associative array with five keys: blockName, attrs, innerBlocks, innerHTML, and innerContent.

    ' + '

    innerHTML is the HTML inside the block with inner blocks stripped out. innerContent is the interleaved version: an array of HTML strings with null placeholders marking where each inner block belongs.

    ' + '

    Footgun: freeform HTML between blocks shows up as a block with blockName === null. Walkers that key off blockName need to handle the null case.

    ', + None), + ('Parse a document', + '

    The simplest possible use. Pass a string, get back a tree.

    ', + ('parse.php', php('''$document = "\\n

    Welcome

    \\n\\n\\n" +\t. "\\n

    Hello from the block editor.

    \\n"; + +$blocks = ( new WP_Block_Parser() )->parse( $document ); +foreach ( $blocks as $block ) { +\tif ( null === $block['blockName'] ) continue; +\techo $block['blockName'] . ': ' . trim( strip_tags( $block['innerHTML'] ) ) . "\\n"; +}'''))), + ('Count every block type in a post', + '

    The first thing most plugin authors actually want: a histogram. Combine recursion with blockName to handle core/columns, core/group, and other containers.

    ', + ('count-blocks.php', php('''$document = "
    " +\t. "

    Title

    " +\t. "

    One.

    " +\t. "

    Two.

    " +\t. "
    " +\t. "
    "; + +$blocks = ( new WP_Block_Parser() )->parse( $document ); + +$counts = array(); +$walk = null; +$walk = function ( $blocks ) use ( &$walk, &$counts ) { +\tforeach ( $blocks as $block ) { +\t\tif ( null !== $block['blockName'] ) { +\t\t\t$counts[ $block['blockName'] ] = isset( $counts[ $block['blockName'] ] ) ? $counts[ $block['blockName'] ] + 1 : 1; +\t\t} +\t\tif ( ! empty( $block['innerBlocks'] ) ) $walk( $block['innerBlocks'] ); +\t} +}; +$walk( $blocks ); + +arsort( $counts ); +foreach ( $counts as $name => $n ) { +\techo str_pad( (string) $n, 4, ' ', STR_PAD_LEFT ) . ' ' . $name . "\\n"; +}'''))), + ('Lint headings for hierarchy mistakes', + '

    "Don\'t skip from h2 to h4" is a real accessibility rule. Walk every core/heading, look at its level attribute (default 2), and warn whenever the level jumps by more than one.

    ', + ('lint-headings.php', php('''$document = "\\n

    Intro

    \\n" +\t. "\\n

    Subsection

    \\n" +\t. "\\n

    Body

    \\n"; + +$blocks = ( new WP_Block_Parser() )->parse( $document ); + +$last = 1; +$index = 0; +$walk = null; +$walk = function ( $blocks ) use ( &$walk, &$last, &$index ) { +\tforeach ( $blocks as $block ) { +\t\tif ( 'core/heading' === $block['blockName'] ) { +\t\t\t$index++; +\t\t\t$level = isset( $block['attrs']['level'] ) ? (int) $block['attrs']['level'] : 2; +\t\t\tif ( $level > $last + 1 ) { +\t\t\t\techo "WARN heading #{$index}: jumped from H{$last} to H{$level}\\n"; +\t\t\t} else { +\t\t\t\techo "ok heading #{$index}: H{$level}\\n"; +\t\t\t} +\t\t\t$last = $level; +\t\t} +\t\tif ( ! empty( $block['innerBlocks'] ) ) $walk( $block['innerBlocks'] ); +\t} +}; +$walk( $blocks );'''))), + ('Find all instances of a custom block', + '

    Useful when auditing an export for a block your plugin owns: every my-plugin/testimonial, with its attributes and inner content.

    ', + ('find-custom-block.php', php('''$document = "

    Reviews

    " +\t. "" +\t. "
    Loved it.
    " +\t. "" +\t. "" +\t. "
    Pretty good.
    " +\t. ""; + +$blocks = ( new WP_Block_Parser() )->parse( $document ); + +$find = null; +$find = function ( $blocks, $name ) use ( &$find ) { +\t$out = array(); +\tforeach ( $blocks as $block ) { +\t\tif ( $name === $block['blockName'] ) $out[] = $block; +\t\tif ( ! empty( $block['innerBlocks'] ) ) $out = array_merge( $out, $find( $block['innerBlocks'], $name ) ); +\t} +\treturn $out; +}; + +foreach ( $find( $blocks, 'my-plugin/testimonial' ) as $i => $b ) { +\techo ( $i + 1 ) . '. ' . $b['attrs']['author'] . ' (' . $b['attrs']['rating'] . '/5): ' +\t\t. trim( strip_tags( $b['innerHTML'] ) ) . "\\n"; +}'''))), + ('Migrate attributes from an old block version', + '

    Block schemas evolve. A common migration: rename textColor to color, or split a combined attribute. Walk the tree, detect the old shape, write a new block array.

    ', + ('migrate-attrs.php', php('''$document = '' +\t. '

    Heads up!

    ' +\t. ''; + +$blocks = ( new WP_Block_Parser() )->parse( $document ); + +$migrate = null; +$migrate = function ( $blocks ) use ( &$migrate ) { +\t$out = array(); +\tforeach ( $blocks as $block ) { +\t\tif ( 'my-plugin/callout' === $block['blockName'] ) { +\t\t\t$attrs = $block['attrs']; +\t\t\tif ( isset( $attrs['textColor'] ) ) { +\t\t\t\t$attrs['color'] = $attrs['textColor']; +\t\t\t\tunset( $attrs['textColor'] ); +\t\t\t} +\t\t\tif ( isset( $attrs['bold'] ) ) { +\t\t\t\t$attrs['fontWeight'] = $attrs['bold'] ? 700 : 400; +\t\t\t\tunset( $attrs['bold'] ); +\t\t\t} +\t\t\t$block['attrs'] = $attrs; +\t\t} +\t\tif ( ! empty( $block['innerBlocks'] ) ) { +\t\t\t$block['innerBlocks'] = $migrate( $block['innerBlocks'] ); +\t\t} +\t\t$out[] = $block; +\t} +\treturn $out; +}; + +$migrated = $migrate( $blocks ); +echo json_encode( $migrated[0]['attrs'], JSON_PRETTY_PRINT ) . "\\n";'''))), + ('Detect blocks with stale embed URLs', + '

    A real-world scrub: find every core/embed whose URL points at a domain you\'ve retired. Useful when auditing a multi-thousand-post export.

    ', + ('audit-embeds.php', php('''$document = '' +\t. '' +\t. ''; + +$retired = array( 'vine.co', 'plus.google.com' ); + +foreach ( ( new WP_Block_Parser() )->parse( $document ) as $b ) { +\tif ( 'core/embed' !== $b['blockName'] ) continue; +\t$url = isset( $b['attrs']['url'] ) ? $b['attrs']['url'] : ''; +\t$host = parse_url( $url, PHP_URL_HOST ); +\t$bad = $host && in_array( $host, $retired, true ); +\techo ( $bad ? 'STALE ' : 'ok ' ) . $url . "\\n"; +}'''))), + ])) + +# =========================================================================== +# 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', + '

    Feed Markdown into MarkdownConsumer, get block markup back. The result is a BlocksWithMetadata object that holds both the rendered blocks and any frontmatter parsed from the document.

    ', + ('quickstart.php', php('''use WordPress\\Markdown\\MarkdownConsumer; + +$result = ( new MarkdownConsumer( "# Hello\\n\\nWelcome to **WordPress**." ) )->consume(); +echo $result->get_block_markup();'''))), + ('Round-trip: blocks back to Markdown', + '

    Pair MarkdownProducer with MarkdownConsumer to convert in either direction. Round-tripping is lossy for block attributes that have no Markdown representation (custom classes, alignment), so do not expect byte-perfect equality.

    ', + ('roundtrip.php', php('''use WordPress\\Markdown\\MarkdownConsumer; +use WordPress\\Markdown\\MarkdownProducer; + +$md = "## Round trip\\n\\n- one\\n- two\\n- three\\n"; +$blocks = ( new MarkdownConsumer( $md ) )->consume(); +$markdown = ( new MarkdownProducer( $blocks ) )->produce(); + +echo $markdown;'''))), + ('Reading YAML frontmatter as post meta', + '

    Frontmatter keys come back as arrays so a single key can hold multiple values. Use get_meta_value() when you only want the first scalar.

    ', + ('frontmatter.php', php('''use WordPress\\Markdown\\MarkdownConsumer; + +$md = <<consume(); + +echo 'Title: ' . $consumer->get_meta_value( 'post_title' ) . "\\n"; +echo 'Status: ' . $consumer->get_meta_value( 'post_status' ) . "\\n"; +print_r( $consumer->get_all_metadata() );'''))), + ('Migrating an Obsidian or Hugo folder of Markdown', + '

    Walk a directory of .md files (Obsidian vault, Hugo content/, Jekyll _posts) and emit one block-markup record per file.

    ', + ('migrate-folder.php', php('''use WordPress\\Markdown\\MarkdownConsumer; + +@mkdir( '/tmp/vault', 0777, true ); +file_put_contents( '/tmp/vault/welcome.md', "---\\ntitle: Welcome\\n---\\n\\nHello world." ); +file_put_contents( '/tmp/vault/roadmap.md', "# Roadmap\\n\\n1. Ship\\n2. Iterate" ); + +foreach ( glob( '/tmp/vault/*.md' ) as $path ) { +\t$consumer = new MarkdownConsumer( file_get_contents( $path ) ); +\t$consumer->consume(); +\t$title = $consumer->get_meta_value( 'title' ); +\tif ( ! $title ) $title = basename( $path, '.md' ); +\techo "=== $title ($path) ===\\n"; +\techo substr( $consumer->get_block_markup(), 0, 120 ) . "...\\n\\n"; +}'''))), + ('Counting blocks produced by a Markdown document', + '

    After conversion, the block markup is plain WordPress block markup, so parse_blocks() works on it directly. The standard way to introspect what the converter emitted before saving to the database.

    ', + ('count-blocks.php', php('''use WordPress\\Markdown\\MarkdownConsumer; + +$md = << A quote. +MD; + +$blocks = ( new MarkdownConsumer( $md ) )->consume()->get_block_markup(); +$counts = array(); +foreach ( parse_blocks( $blocks ) as $block ) { +\tif ( ! $block['blockName'] ) continue; +\t$counts[ $block['blockName'] ] = ( isset( $counts[ $block['blockName'] ] ) ? $counts[ $block['blockName'] ] : 0 ) + 1; +} +print_r( $counts );'''))), + ])) + +# =========================================================================== +# XML +# =========================================================================== +COMPONENTS.append(('xml', 'XML', + 'A streaming, namespace-aware XML processor in pure PHP. Read and modify huge feeds, WXR exports, ePub manifests, and Office Open XML parts without ever loading the document into memory and without depending on libxml2.', + 'wp-php-toolkit/xml', + [ + ('Why a streaming XML processor', + '

    SimpleXMLElement and DOMDocument both need libxml2 and both build a complete in-memory tree. XMLProcessor walks the document forward as a cursor, keeps modifications in a side buffer, and emits the full updated XML with get_updated_xml() only when you ask for it.

    ' + '

    Footgun #1: namespaces are addressed by URI, never by prefix. get_attribute( \'wp\', \'status\' ) always returns null; you want get_attribute( \'http://wordpress.org/export/1.2/\', \'status\' ).

    ' + '

    Footgun #2: in streaming mode next_tag() can return false because input ran out, not because the document ended. Check is_paused_at_incomplete_input() before assuming you\'re done.

    ', + None), + ('Bump every price in a catalog', + '

    Find each <book>, read its price, write a new one, emit the updated document.

    ', + ('bump-prices.php', php('''use WordPress\\XML\\XMLProcessor; + +$xml = '' +\t. 'PHP Internals' +\t. 'WordPress at Scale' +\t. ''; + +$p = XMLProcessor::create_from_string( $xml ); +while ( $p->next_tag( 'book' ) ) { +\t$old = (float) $p->get_attribute( '', 'price' ); +\t$new = number_format( $old * 1.10, 2, '.', '' ); +\t$p->set_attribute( '', 'price', $new ); +} + +echo $p->get_updated_xml();'''))), + ('Read namespaced attributes from a WXR export', + '

    WordPress\'s WXR uses wp:, dc:, and content: prefixes. Always pass the URI, not the prefix; the processor handles whichever prefix the document actually uses.

    ', + ('wxr-namespaces.php', php('''use WordPress\\XML\\XMLProcessor; + +$wxr = '' +\t. '' +\t. '' +\t. 'Hello World' +\t. 'admin' +\t. '42' +\t. 'publish' +\t. ''; + +$WP = 'http://wordpress.org/export/1.2/'; +$DC = 'http://purl.org/dc/elements/1.1/'; + +$p = XMLProcessor::create_from_string( $wxr ); +while ( $p->next_tag( 'item' ) ) { +\twhile ( $p->next_token() ) { +\t\tif ( $p->is_tag_closer() && 'item' === $p->get_tag_local_name() ) break; +\t\tif ( ! $p->is_tag_opener() ) continue; +\t\t$ns = $p->get_tag_namespace(); +\t\t$local = $p->get_tag_local_name(); +\t\t$prefix = ( $WP === $ns ) ? 'wp/' : ( ( $DC === $ns ) ? 'dc/' : '' ); +\t\techo "{$prefix}{$local}: "; +\t\twhile ( $p->next_token() && '#text' !== $p->get_token_name() ) {} +\t\techo trim( $p->get_modifiable_text() ) . "\\n"; +\t} +}'''))), + ('Rewrite URLs across an entire WXR export', + '

    WXR holds tens of thousands of URLs in <link>, <guid>, and post content. Streaming the file lets you rewrite multi-hundred-megabyte exports without going OOM.

    ', + ('rewrite-wxr-urls.php', php('''use WordPress\\XML\\XMLProcessor; + +$wxr = '' +\t. 'https://old.example.com' +\t. 'https://old.example.com/2024/post-1' +\t. 'https://old.example.com/?p=1' +\t. ''; + +$from = 'https://old.example.com'; +$to = 'https://new.example.com'; + +$p = XMLProcessor::create_from_string( $wxr ); +$rewritten = 0; + +while ( $p->next_token() ) { +\tif ( '#text' !== $p->get_token_name() ) continue; +\t$text = $p->get_modifiable_text(); +\tif ( false === strpos( $text, $from ) ) continue; +\t$p->set_modifiable_text( str_replace( $from, $to, $text ) ); +\t$rewritten++; +} + +echo "rewrote {$rewritten} text nodes\\n\\n"; +echo $p->get_updated_xml();'''))), + ('Parse OPML to extract feed URLs', + '

    OPML is the format Feedly and many readers use to import/export feed lists. Flat, attribute-heavy XML — exactly what a tag processor handles best.

    ', + ('opml.php', php('''use WordPress\\XML\\XMLProcessor; + +$opml = 'My Feeds' +\t. '' +\t. '' +\t. '' +\t. '' +\t. ''; + +$p = XMLProcessor::create_from_string( $opml ); +while ( $p->next_tag( 'outline' ) ) { +\t$url = $p->get_attribute( '', 'xmlUrl' ); +\tif ( null === $url ) continue; +\techo $p->get_attribute( '', 'text' ) . "\\t" . $url . "\\n"; +}'''))), + ])) + +# =========================================================================== +# Encoding +# =========================================================================== +COMPONENTS.append(('encoding', 'Encoding', + 'Pure-PHP UTF-8 validation and scrubbing. Detects malformed bytes, replaces them per the Unicode maximal-subpart algorithm, and works without mbstring.', + 'wp-php-toolkit/encoding', + [ + ('Validating UTF-8 before storing it', + '

    wp_is_valid_utf8() rejects overlong sequences, surrogate halves, and stray ISO-8859-1 bytes. Use it as a guard in front of any code path that assumes UTF-8 (database, JSON, XML).

    ', + ('validate.php', php('''use function WordPress\\Encoding\\wp_is_valid_utf8; + +var_dump( wp_is_valid_utf8( 'just a test' ) ); +var_dump( wp_is_valid_utf8( "\\xE2\\x9C\\x8F" ) ); +var_dump( wp_is_valid_utf8( "B\\xFCch" ) ); +var_dump( wp_is_valid_utf8( "\\xC1\\xBF" ) ); +var_dump( wp_is_valid_utf8( "\\xED\\xB0\\x80" ) );'''))), + ('Scrubbing invalid bytes with U+FFFD', + '

    Replace each ill-formed sequence with the Unicode replacement character. Useful right before serializing to XML, JSON, or sending to an LLM that will choke on broken bytes.

    ', + ('scrub.php', php('''use function WordPress\\Encoding\\wp_scrub_utf8; + +$broken = "the byte \\xC0 should not be here."; +echo wp_scrub_utf8( $broken ) . "\\n"; + +echo wp_scrub_utf8( ".\\xE2\\x8C\\xE2\\x8C." ) . "\\n";'''))), + ('Detecting noncharacters MySQL/utf8mb4 will reject', + '

    Code points like U+FFFE, U+FFFF, and the U+FDD0–U+FDEF block are valid Unicode but forbidden in XML and rejected by some databases. Check before inserting user-submitted content into a strict utf8mb4 column.

    ', + ('noncharacters.php', php('''use function WordPress\\Encoding\\wp_has_noncharacters; + +var_dump( wp_has_noncharacters( 'normal text' ) ); +var_dump( wp_has_noncharacters( "oops \\u{FFFE}" ) ); +var_dump( wp_has_noncharacters( "hi \\u{FDD0} bye" ) );'''))), + ('Three-way pipeline: validate, scrub, then check noncharacters', + '

    Real-world inputs are messy: an old WXR export, a CSV with mixed encodings, a paste from Word. Combination of validate + scrub + noncharacter-check covers the three classes of breakage that bite later.

    ', + ('pipeline.php', php('''use function WordPress\\Encoding\\wp_is_valid_utf8; +use function WordPress\\Encoding\\wp_scrub_utf8; +use function WordPress\\Encoding\\wp_has_noncharacters; + +$inputs = array( +\t'good' => 'Café', +\t'latin1' => "caf\\xE9", +\t'overlong' => "x\\xC1\\xBFy", +\t'noncharac' => "hi \\u{FFFE} there", +); + +foreach ( $inputs as $label => $bytes ) { +\t$valid = wp_is_valid_utf8( $bytes ); +\t$cleaned = wp_scrub_utf8( $bytes ); +\t$weird = wp_has_noncharacters( $cleaned ); +\techo sprintf( "%-10s valid=%s noncharacter=%s -> %s\\n", $label, $valid ? 'Y' : 'N', $weird ? 'Y' : 'N', $cleaned ); +}'''))), + ('Salvaging a legacy ISO-8859-1 column inside a UTF-8 corpus', + '

    Old WordPress databases sometimes mix encodings: most rows are UTF-8 but a few were stored as latin-1. Detect the bad rows with wp_is_valid_utf8() and only re-encode those.

    ', + ('mixed-encoding.php', php('''use function WordPress\\Encoding\\wp_is_valid_utf8; +use function WordPress\\Encoding\\wp_scrub_utf8; + +$rows = array( +\t1 => 'Plain ASCII', +\t2 => 'Café', +\t3 => "caf\\xE9", +\t4 => "weird \\xC0 byte", +); + +foreach ( $rows as $id => $value ) { +\tif ( wp_is_valid_utf8( $value ) ) { +\t\techo "#$id ok: $value\\n"; +\t\tcontinue; +\t} +\t$converted = @iconv( 'ISO-8859-1', 'UTF-8', $value ); +\tif ( false !== $converted && wp_is_valid_utf8( $converted ) ) { +\t\techo "#$id recovered as latin1: $converted\\n"; +\t} else { +\t\techo "#$id unrecoverable, scrubbing: " . wp_scrub_utf8( $value ) . "\\n"; +\t} +}'''))), + ])) + +# =========================================================================== +# DataLiberation +# =========================================================================== +COMPONENTS.append(('dataliberation', 'DataLiberation', + 'Streaming WordPress import/export. WXR, SQL, block markup — without loading whole datasets into memory.', + 'wp-php-toolkit/data-liberation', + [ + ('Write a WXR file in five lines', + '

    Stream a single post into a WXR document via WXRWriter. The writer holds no buffer beyond what is needed to close currently-open tags, so memory stays flat regardless of input size.

    ', + ('wxr-quickstart.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\DataLiberation\\EntityWriter\\WXRWriter; +use WordPress\\DataLiberation\\ImportEntity; + +$pipe = new MemoryPipe(); +$writer = new WXRWriter( $pipe ); +$writer->append_entity( new ImportEntity( 'post', array( +\t'post_title' => 'Hello', +\t'content' => 'World.', +\t'post_id' => '1', +\t'status' => 'publish', +) ) ); +$writer->finalize(); +$writer->close_writing(); +$pipe->close_writing(); +echo $pipe->consume_all();'''))), + ('Build a WXR programmatically from any source', + '

    The writer doesn\'t care where entities come from. Loop over rows from a CMS, a CSV, or a Notion API dump and emit posts plus their meta and comments.

    ', + ('build-wxr.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\DataLiberation\\EntityWriter\\WXRWriter; +use WordPress\\DataLiberation\\ImportEntity; + +$rows = array( +\tarray( 'id' => 10, 'title' => 'About', 'body' => '

    About us.

    ', 'tags' => array( 'company' ) ), +\tarray( 'id' => 11, 'title' => 'Blog', 'body' => '

    Hello world.

    ', 'tags' => array( 'news', 'launch' ) ), +); + +$pipe = new MemoryPipe(); +$writer = new WXRWriter( $pipe ); + +foreach ( $rows as $row ) { +\t$writer->append_entity( new ImportEntity( 'post', array( +\t\t'post_id' => (string) $row['id'], +\t\t'post_title' => $row['title'], +\t\t'content' => $row['body'], +\t\t'status' => 'publish', +\t\t'post_type' => 'post', +\t) ) ); +\tforeach ( $row['tags'] as $i => $tag ) { +\t\t$writer->append_entity( new ImportEntity( 'term', array( +\t\t\t'term_id' => (string) ( $row['id'] * 100 + $i ), +\t\t\t'taxonomy' => 'post_tag', +\t\t\t'slug' => $tag, +\t\t\t'parent' => '0', +\t\t) ) ); +\t} +} + +$writer->finalize(); +$writer->close_writing(); +$pipe->close_writing(); + +echo $pipe->consume_all();'''))), + ('Read entities from a WXR file with constant memory', + '

    WXREntityReader emits one entity at a time. A 10 GB WXR uses the same memory as a 10 KB one.

    ', + ('wxr-read.php', php('''use WordPress\\DataLiberation\\EntityReader\\WXREntityReader; + +$wxr = << + + +Demo +First1postBody 1 +Second2postBody 2 + + +XML; + +$reader = WXREntityReader::create(); +$reader->append_bytes( $wxr ); +$reader->input_finished(); + +while ( $reader->next_entity() ) { +\t$entity = $reader->get_entity(); +\techo $entity->get_type() . ': ' . json_encode( $entity->get_data() ) . "\\n"; +}'''))), + ('Streaming transform: rewrite URLs while copying WXR', + '

    Wire reader to writer to rewrite a WXR file on the fly. This pattern is how you migrate a staging export to production: swap staging.example.com for example.com without ever loading the file into memory.

    ', + ('rewrite-urls.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\DataLiberation\\EntityReader\\WXREntityReader; +use WordPress\\DataLiberation\\EntityWriter\\WXRWriter; +use WordPress\\DataLiberation\\ImportEntity; + +$source_xml = << + + +Hello1post +Visit https://staging.example.com/about for more. + + +XML; + +$reader = WXREntityReader::create(); +$reader->append_bytes( $source_xml ); +$reader->input_finished(); + +$out_pipe = new MemoryPipe(); +$writer = new WXRWriter( $out_pipe ); + +while ( $reader->next_entity() ) { +\t$entity = $reader->get_entity(); +\t$data = $entity->get_data(); +\tforeach ( array( 'post_content', 'content', 'description' ) as $field ) { +\t\tif ( isset( $data[ $field ] ) ) { +\t\t\t$data[ $field ] = str_replace( 'staging.example.com', 'example.com', $data[ $field ] ); +\t\t} +\t} +\tif ( 'post' === $entity->get_type() ) { +\t\t$data['content'] = isset( $data['post_content'] ) ? $data['post_content'] : ( isset( $data['content'] ) ? $data['content'] : '' ); +\t} +\t$writer->append_entity( new ImportEntity( $entity->get_type(), $data ) ); +} + +$writer->finalize(); +$writer->close_writing(); +$out_pipe->close_writing(); + +echo $out_pipe->consume_all();'''))), + ('Render Markdown into a WXR import in one pipeline', + '

    Compose MarkdownConsumer with WXRWriter to publish a folder of Markdown directly as a WordPress import file.

    ', + ('md-to-wxr.php', php('''use WordPress\\ByteStream\\MemoryPipe; +use WordPress\\DataLiberation\\EntityWriter\\WXRWriter; +use WordPress\\DataLiberation\\ImportEntity; +use WordPress\\Markdown\\MarkdownConsumer; + +@mkdir( '/tmp/md-src', 0777, true ); +file_put_contents( '/tmp/md-src/hello.md', "---\\ntitle: Hello\\n---\\n\\n# Hello\\n\\nFirst post." ); +file_put_contents( '/tmp/md-src/second.md', "---\\ntitle: Second\\n---\\n\\nMore text **here**." ); + +$pipe = new MemoryPipe(); +$writer = new WXRWriter( $pipe ); + +$id = 1; +foreach ( glob( '/tmp/md-src/*.md' ) as $path ) { +\t$consumer = new MarkdownConsumer( file_get_contents( $path ) ); +\t$consumer->consume(); +\t$writer->append_entity( new ImportEntity( 'post', array( +\t\t'post_id' => (string) $id++, +\t\t'post_title' => $consumer->get_meta_value( 'title' ) ?: basename( $path, '.md' ), +\t\t'content' => $consumer->get_block_markup(), +\t\t'status' => 'publish', +\t\t'post_type' => 'post', +\t\t'post_name' => basename( $path, '.md' ), +\t) ) ); +} + +$writer->finalize(); +$writer->close_writing(); +$pipe->close_writing(); + +echo $pipe->consume_all();'''))), + ])) + +# =========================================================================== +# Git +# =========================================================================== +COMPONENTS.append(('git', 'Git', + 'A pure-PHP Git client and server. Commits, branches, diffs, HTTP push/pull — all without shelling out to git.', + 'wp-php-toolkit/git', + [ + ('Commit files into an in-memory repo', + '

    The simplest possible repository: an InMemoryFilesystem as object storage and one commit() call. Reach for this in tests, in WP-CLI snapshots, or any place you want versioning without touching disk.

    ', + ('commit-in-memory.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Git\\GitRepository; + +$repo = new GitRepository( InMemoryFilesystem::create() ); + +$oid = $repo->commit( array( +\t'updates' => array( +\t\t'README.md' => "# My Project\\n", +\t\t'src/hello-world.php' => 'get_branch_tip( 'HEAD' ) . "\\n"; +echo "README: " . $repo->read_object_by_path( '/README.md' )->consume_all();'''))), + ('Walk the commit history', + '

    Follow the parent chain from HEAD backwards. Building block for a WP-CLI "post revisions" log or a "what changed since release X" report.

    ', + ('walk-history.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Git\\GitRepository; +use WordPress\\Git\\Model\\Commit; + +$repo = new GitRepository( InMemoryFilesystem::create() ); +foreach ( array( 'add intro', 'fix typo', 'expand examples' ) as $i => $msg ) { +\t$repo->commit( array( +\t\t'updates' => array( 'post.md' => "# Draft {$i}" ), +\t\t'commit' => array( 'message' => $msg ), +\t) ); +} + +$oid = $repo->get_branch_tip( 'HEAD' ); +while ( ! Commit::is_null_hash( $oid ) ) { +\t$c = $repo->read_object( $oid )->as_commit(); +\techo substr( $c->hash, 0, 7 ) . ' ' . trim( $c->message ) . "\\n"; +\t$oid = $c->get_first_parent_hash(); +\tif ( ! $oid || ! $repo->has_object( $oid ) ) break; +}'''))), + ('Treat a repository like a filesystem', + '

    GitFilesystem wraps a repository in the standard Filesystem interface. Every put_contents() auto-commits.

    ', + ('git-filesystem.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Git\\GitFilesystem; +use WordPress\\Git\\GitRepository; + +$repo = new GitRepository( InMemoryFilesystem::create() ); +$fs = GitFilesystem::create( $repo ); + +$fs->put_contents( '/posts/hello.md', "# Hello\\nFirst draft." ); +$fs->put_contents( '/posts/about.md', "# About\\nWho we are." ); +$fs->put_contents( '/posts/hello.md', "# Hello\\nSecond draft." ); + +echo "tree:\\n"; +foreach ( $fs->ls( '/posts' ) as $name ) { +\techo " /posts/{$name}\\n"; +} +echo "\\nhello.md now:\\n" . $fs->get_contents( '/posts/hello.md' ) . "\\n";'''))), + ('Branch, edit, and switch back', + '

    Create a feature branch off the current commit, change files, flip HEAD back. Useful for experimental edits in collaborative tools.

    ', + ('branches.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Git\\GitRepository; + +$repo = new GitRepository( InMemoryFilesystem::create() ); +$base = $repo->commit( array( +\t'updates' => array( 'config.json' => '{"flag":false}' ), +\t'commit' => array( 'message' => 'baseline' ), +) ); + +$repo->create_branch( 'refs/heads/experiment', $base ); +$repo->checkout( 'refs/heads/experiment' ); +$repo->commit( array( +\t'updates' => array( 'config.json' => '{"flag":true}' ), +\t'commit' => array( 'message' => 'flip the flag' ), +) ); + +echo "on experiment: " . $repo->read_object_by_path( '/config.json' )->consume_all() . "\\n"; + +$repo->checkout( 'refs/heads/trunk' ); +echo "on trunk: " . $repo->read_object_by_path( '/config.json' )->consume_all() . "\\n";'''))), + ('Three-way merge two branches', + '

    The classic Git workflow: branch off, edit on each side, merge. $repo->merge() finds the common ancestor, three-way-merges every file, and creates a merge commit.

    ', + ('merge-branches.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Git\\GitRepository; + +$repo = new GitRepository( InMemoryFilesystem::create() ); +$base = $repo->commit( array( 'updates' => array( +\t'todo.txt' => "buy milk\\nwalk dog\\nread book\\n", +) ) ); + +$repo->commit( array( 'updates' => array( +\t'todo.txt' => "buy oat milk\\nwalk dog\\nread book\\n", +) ) ); + +$repo->create_branch( 'refs/heads/feature', $base ); +$repo->checkout( 'refs/heads/feature' ); +$repo->commit( array( 'updates' => array( +\t'todo.txt' => "buy milk\\nwalk dog\\nread book\\nwrite blog post\\n", +) ) ); + +$repo->checkout( 'refs/heads/trunk' ); +$result = $repo->merge( 'refs/heads/feature' ); + +echo "merge head: {$result['new_head']}\\n"; +echo "conflicts: " . ( $result['conflicts'] ? implode( ',', $result['conflicts'] ) : 'none' ) . "\\n"; +echo "result:\\n" . $repo->read_object_by_path( '/todo.txt' )->consume_all();'''))), + ('Snapshot WordPress options into a repo', + '

    Serialize a chunk of WP state (options, post meta, a theme config) on every save and commit it. You get free history, diffs between snapshots, and a "rollback to last week" button.

    ', + ('options-snapshot.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; +use WordPress\\Git\\GitRepository; + +$repo = new GitRepository( InMemoryFilesystem::create() ); + +$snapshots = array( +\tarray( 'blogname' => 'My Site', 'posts_per_page' => 10, 'timezone_string' => 'UTC' ), +\tarray( 'blogname' => 'My Site', 'posts_per_page' => 20, 'timezone_string' => 'UTC' ), +\tarray( 'blogname' => 'New Name', 'posts_per_page' => 20, 'timezone_string' => 'Europe/Warsaw' ), +); + +foreach ( $snapshots as $i => $options ) { +\t$repo->commit( array( +\t\t'updates' => array( 'options.json' => json_encode( $options, JSON_PRETTY_PRINT ) ), +\t\t'commit' => array( 'message' => "snapshot #{$i}" ), +\t) ); +} + +$head = $repo->get_branch_tip( 'HEAD' ); +$parent = $repo->read_object( $head )->as_commit()->get_first_parent_hash(); +$diff = $repo->diff_commits( $head, $parent ); + +echo "Files changed in last snapshot:\\n"; +foreach ( $diff as $name => $entry ) { +\techo " {$name}\\n"; +}'''))), + ])) + +# =========================================================================== +# Merge +# =========================================================================== +COMPONENTS.append(('merge', 'Merge', + 'Three-way merge and diff. Pluggable differ + merger + optional validator.', + 'wp-php-toolkit/merge', + [ + ('Diff two strings line by line', + '

    Feed two strings to LineDiffer and inspect the operations. Every get_changes() entry is a [op, text] pair.

    ', + ('line-diff.php', php('''use WordPress\\Merge\\Diff\\Diff; +use WordPress\\Merge\\Diff\\LineDiffer; + +$diff = ( new LineDiffer() )->diff( +\t"alpha\\nbeta\\ngamma\\n", +\t"alpha\\nBETA\\ngamma\\ndelta\\n" +); + +$labels = array( Diff::DIFF_EQUAL => '=', Diff::DIFF_DELETE => '-', Diff::DIFF_INSERT => '+' ); +foreach ( $diff->get_changes() as $change ) { +\techo $labels[ $change[0] ] . ' ' . rtrim( $change[1] ) . "\\n"; +}'''))), + ('Render a unified patch', + '

    format_as_git_patch() produces output that mirrors git diff, including hunk headers — handy for emails, CI annotations, or a "what changed?" panel.

    ', + ('git-patch.php', php('''use WordPress\\Merge\\Diff\\LineDiffer; + +$old = "title: Hello\\nauthor: Alice\\nstatus: draft\\n"; +$new = "title: Hello, world\\nauthor: Alice\\nstatus: published\\ntags: greeting\\n"; + +$diff = ( new LineDiffer() )->diff( $old, $new ); +echo $diff->format_as_git_patch( array( +\t'a_source' => 'a/post.yml', +\t'b_source' => 'b/post.yml', +) );'''))), + ('Three-way merge with no conflicts', + '

    The classic case: each branch changes a different region. Pass the common ancestor plus both edits to MergeStrategy::merge() and read the merged result.

    ', + ('three-way.php', php('''use WordPress\\Merge\\Diff\\LineDiffer; +use WordPress\\Merge\\Merge\\LineMerger; +use WordPress\\Merge\\MergeStrategy; + +$strategy = new MergeStrategy( new LineDiffer(), new LineMerger() ); + +$result = $strategy->merge( +\t"intro\\nbody\\noutro\\n", +\t"intro updated\\nbody\\noutro\\n", +\t"intro\\nbody\\noutro\\nappendix\\n" +); + +echo $result->has_conflicts() ? "conflicts!\\n" : "clean merge:\\n"; +echo $result->get_merged_content();'''))), + ('Inspect and surface conflicts', + '

    When both sides edit the same region, the merger produces a MergeConflict. The merged content carries Git-style markers, but the structured get_conflicts() output is what you want for a UI that lets the user pick a side.

    ', + ('conflicts.php', php('''use WordPress\\Merge\\Diff\\LineDiffer; +use WordPress\\Merge\\Merge\\LineMerger; +use WordPress\\Merge\\MergeStrategy; + +$strategy = new MergeStrategy( new LineDiffer(), new LineMerger() ); +$result = $strategy->merge( +\t"line 1\\nline 2\\n", +\t"line 1\\nline 2 from Alice\\n", +\t"line 1\\nline 2 from Bob\\n" +); + +if ( $result->has_conflicts() ) { +\tforeach ( $result->get_conflicts() as $c ) { +\t\techo "ours: " . trim( $c->ours ) . "\\n"; +\t\techo "theirs: " . trim( $c->theirs ) . "\\n"; +\t} +} +echo "\\n--- merged content with markers ---\\n"; +echo $result->get_merged_content();'''))), + ('Sync a Markdown folder against an edited DB copy', + '

    A real-world scenario: posts live both in a Git-tracked Markdown folder and in WordPress, and someone edits each. Three-way-merge each post against its common ancestor.

    ', + ('sync-folder-vs-db.php', php('''use WordPress\\Merge\\Diff\\LineDiffer; +use WordPress\\Merge\\Merge\\LineMerger; +use WordPress\\Merge\\MergeStrategy; + +$strategy = new MergeStrategy( new LineDiffer(), new LineMerger() ); + +$posts = array( +\t'hello.md' => array( +\t\t'base' => "# Hello\\nDraft body.\\n", +\t\t'disk' => "# Hello\\nDraft body, expanded on disk.\\n", +\t\t'db' => "# Hello\\nDraft body.\\nNew section from the editor.\\n", +\t), +\t'about.md' => array( +\t\t'base' => "# About\\nWho we are.\\n", +\t\t'disk' => "# About\\nWho *they* are.\\n", +\t\t'db' => "# About\\nWho we really are.\\n", +\t), +); + +foreach ( $posts as $name => $sides ) { +\t$result = $strategy->merge( $sides['base'], $sides['disk'], $sides['db'] ); +\techo "=== {$name} ===\\n"; +\techo $result->has_conflicts() ? "(conflict — needs review)\\n" : "(auto-merged)\\n"; +\techo $result->get_merged_content() . "\\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 examples below show the API; real network calls 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";'''))), + ('Inspect headers without reading the body', + '

    Call await_response() to get the Response as soon as headers arrive. Useful for HEAD-style metadata checks, content-type sniffing, or deciding whether to keep reading.

    ', + ('head-metadata.php', php('''use WordPress\\HttpClient\\Client; +use WordPress\\HttpClient\\Request; + +$client = new Client(); +$request = new Request( 'https://wordpress.org/latest.zip', array( +\t'method' => 'HEAD', +) ); + +$stream = $client->fetch( $request ); +$response = $stream->await_response(); + +echo "Status: " . $response->status_code . " " . $response->get_reason_phrase() . "\\n"; +echo "Type: " . $response->get_header( 'content-type' ) . "\\n"; +echo "Size: " . $response->get_header( 'content-length' ) . " bytes\\n";'''))), + ('POST JSON with a request body', + '

    Stream a JSON body up to the server using MemoryPipe. Same pattern works for any payload by switching the content-type header.

    ', + ('post-json.php', php('''use WordPress\\HttpClient\\Client; +use WordPress\\HttpClient\\Request; +use WordPress\\ByteStream\\MemoryPipe; + +$payload = json_encode( array( 'title' => 'Hello', 'tags' => array( 'http', 'php' ) ) ); + +$client = new Client(); +$request = new Request( 'https://httpbin.org/post', array( +\t'method' => 'POST', +\t'headers' => array( +\t\t'content-type' => 'application/json', +\t\t'content-length' => (string) strlen( $payload ), +\t), +\t'body_stream' => new MemoryPipe( $payload ), +) ); + +$response = $client->fetch( $request )->json(); +echo "Server saw body: " . $response['data'] . "\\n";'''))), + ('Parallel fan-out: fetch many URLs at once', + '

    Enqueue a batch of requests and react to events as they fire. The client multiplexes them — total wall time is roughly the slowest request, not the sum.

    ', + ('fan-out.php', php('''use WordPress\\HttpClient\\Client; +use WordPress\\HttpClient\\Request; + +$urls = array( +\t'https://wordpress.org/', +\t'https://make.wordpress.org/', +\t'https://developer.wordpress.org/', +); + +$client = new Client(); +$client->enqueue( array_map( function ( $url ) { +\treturn new Request( $url, array( 'method' => 'HEAD' ) ); +}, $urls ) ); + +$results = array(); +while ( $client->await_next_event() ) { +\t$request = $client->get_request(); +\tif ( Client::EVENT_GOT_HEADERS === $client->get_event() ) { +\t\t$results[ $request->url ] = $request->response->status_code; +\t} elseif ( Client::EVENT_FAILED === $client->get_event() ) { +\t\t$results[ $request->url ] = 'ERR ' . $request->error->message; +\t} +} + +foreach ( $results as $url => $status ) { +\tprintf( "%-40s %s\\n", $url, $status ); +}'''))), + ('Stream a download to disk without OOM', + '

    Process the body chunk-by-chunk via the event loop. Memory stays flat regardless of file size.

    ', + ('stream-to-disk.php', php('''use WordPress\\HttpClient\\Client; +use WordPress\\HttpClient\\Request; + +$dest = sys_get_temp_dir() . '/wp-readme.html'; +$client = new Client(); +$client->enqueue( array( new Request( 'https://wordpress.org/' ) ) ); + +$bytes = 0; +@unlink( $dest ); + +while ( $client->await_next_event() ) { +\tswitch ( $client->get_event() ) { +\t\tcase Client::EVENT_BODY_CHUNK_AVAILABLE: +\t\t\t$chunk = $client->get_response_body_chunk(); +\t\t\t$bytes += strlen( $chunk ); +\t\t\tfile_put_contents( $dest, $chunk, FILE_APPEND ); +\t\t\tbreak; +\t\tcase Client::EVENT_FINISHED: +\t\t\techo "Wrote {$bytes} bytes to {$dest}\\n"; +\t\t\tbreak; +\t} +} + +echo "Peak memory: " . round( memory_get_peak_usage( true ) / 1024 / 1024, 2 ) . " MB\\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', + [ + ('Hello world on port 8080', + '

    Run on your machine: the Playground sandbox does not allow processes to bind listening TCP ports. Save this snippet locally and run php hello-server.php.

    ', + ('hello-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 from " . $request->method . " " . $request->url . "\\n" ); +} ); + +$server->serve( function ( $host, $port ) { +\techo "Listening on http://{$host}:{$port}\\n"; +} );''')), + ('A tiny JSON router', + '

    Run on your machine: needs a listening port. Once running, try curl localhost:8080/api/status.

    ' + '

    Build a CLI tool with a web UI by switching on the parsed path and method.

    ', + ('mini-router.php', '''set_handler( function ( IncomingRequest $request, ResponseWriteStream $response ) { +\t$path = $request->get_parsed_url()->pathname; + +\tif ( '/api/status' === $path ) { +\t\t$response->send_http_code( 200 ); +\t\t$response->send_header( 'Content-Type', 'application/json' ); +\t\t$response->append_bytes( json_encode( array( +\t\t\t'ok' => true, +\t\t\t'pid' => getmypid(), +\t\t\t'memory' => memory_get_usage( true ), +\t\t) ) ); +\t\treturn; +\t} + +\tif ( '/api/echo' === $path && 'POST' === $request->method ) { +\t\t$body = ''; +\t\twhile ( ! $request->body_stream->reached_end_of_data() ) { +\t\t\t$n = $request->body_stream->pull( 4096 ); +\t\t\tif ( $n > 0 ) $body .= $request->body_stream->consume( $n ); +\t\t} +\t\t$response->send_http_code( 200 ); +\t\t$response->send_header( 'Content-Type', 'text/plain' ); +\t\t$response->append_bytes( $body ); +\t\treturn; +\t} + +\t$response->send_http_code( 404 ); +\t$response->append_bytes( "Not found\\n" ); +} ); + +$server->serve();''')), + ('Buffered response with auto Content-Length', + '

    Use BufferingResponseWriter when you want the framework to compute Content-Length for you, or when the runtime is CGI-shaped and expects the full body up front. This one runs anywhere — no socket required.

    ', + ('buffered-writer.php', php('''use WordPress\\HttpServer\\Response\\BufferingResponseWriter; + +$writer = new BufferingResponseWriter(); +$writer->send_http_code( 200 ); +$writer->send_header( 'Content-Type', 'text/html' ); +$writer->append_bytes( 'Hi

    Hello

    ' ); +$writer->append_bytes( '

    Generated at ' . date( 'c' ) . '

    ' ); +$writer->close_writing(); + +echo "Captured response:\\n\\n"; +echo $writer->get_buffer();'''))), + ])) + +# =========================================================================== +# 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', + [ + ('Run the proxy locally', + '

    Run on your machine: the proxy needs to listen on a port. Start PHP\'s built-in server and request any HTTPS URL through it.

    ' + '
    PLAYGROUND_CORS_PROXY_DISABLE_RATE_LIMIT=1 \\\n  php -S 127.0.0.1:5263 vendor/wp-php-toolkit/corsproxy/cors-proxy.php\n\n# In another terminal:\ncurl -s "http://127.0.0.1:5263/cors-proxy.php/https://api.github.com/repos/WordPress/php-toolkit" | head\n
    ', + None), + ('Production rate limiting', + '

    Drop a cors-proxy-config.php next to cors-proxy.php. The proxy refuses to boot without one — that is the point.

    ' + '

    This example uses a per-IP token bucket stored on disk. Replace with Redis / memcached for multi-host deployments.

    ', + ('cors-proxy-config.php', ''' $now - $window; +\t} ); + +\tif ( count( $hits ) >= $max_req ) { +\t\theader( 'Retry-After: ' . $window ); +\t\thttp_response_code( 429 ); +\t\techo 'Rate limit exceeded'; +\t\texit; +\t} + +\t$hits[] = $now; +\tfile_put_contents( $bucket, json_encode( array_values( $hits ) ) ); +} + +echo "Config loaded — rate limiter armed.\\n";''')), + ('Allowlist upstream hosts', + '

    Out of the box the proxy will fetch any public URL. Most real deployments want a fixed list of upstreams — GitHub, Packagist, wp.org.

    ', + ('allowlist-config.php', '''Once deployed, the client side is just fetch() with the proxy URL. Drop this into any HTML page.

    ' + '
    const PROXY = "https://cors.example.com/cors-proxy.php";\n\nasync function viaProxy(url, init = {}) {\n  const res = await fetch(`${PROXY}/${url}`, {\n    ...init,\n    headers: {\n      ...(init.headers || {}),\n      "X-Cors-Proxy-Allowed-Request-Headers": "Authorization",\n    },\n  });\n  if (!res.ok) throw new Error(`Proxy returned ${res.status}`);\n  return res;\n}\n\nconst repo = await viaProxy("https://api.github.com/repos/WordPress/php-toolkit").then(r => r.json());\nconsole.log(repo.full_name, repo.stargazers_count);\n
    ', + None), + ('Deploy behind nginx', + '

    The proxy is a single PHP script — any SAPI works. nginx + php-fpm is a common production setup. PATH_INFO is what the proxy reads to learn the target URL.

    ' + '
    server {\n  listen 443 ssl http2;\n  server_name cors.example.com;\n\n  root /var/www/cors-proxy;\n  index cors-proxy.php;\n\n  location ~ ^/cors-proxy\\.php(/.*)?$ {\n    fastcgi_pass unix:/run/php/php8.1-fpm.sock;\n    fastcgi_split_path_info ^(.+\\.php)(/.*)$;\n    fastcgi_param SCRIPT_FILENAME $document_root/cors-proxy.php;\n    fastcgi_param PATH_INFO $fastcgi_path_info;\n    include fastcgi_params;\n  }\n}\n
    ', + None), + ])) + +# =========================================================================== +# CLI +# =========================================================================== +COMPONENTS.append(('cli', 'CLI', + 'POSIX-style argument parser. Long options, short bundles, inline values, positional args — one static call.', + 'wp-php-toolkit/cli', + [ + ('Why this exists', + '

    Real CLI tools in PHP usually mean either pulling in symfony/console (and 30+ transitive packages) or hand-rolling argv parsing that breaks the first time someone writes -vvv or --port=8080. The toolkit\'s CLI class is one static method, no dependencies, and handles the POSIX shapes you actually see.

    ', + None), + ('Parse a single flag', + '

    The smallest useful invocation: one boolean flag, one positional. Each option is a four-tuple of [ short, has_value, default, description ].

    ', + ('parse-flag.php', php('''use WordPress\\CLI\\CLI; + +$option_defs = array( +\t'verbose' => array( 'v', false, false, 'Enable verbose output' ), +); + +list( $positionals, $options ) = CLI::parse_command_args_and_options( +\tarray( '-v', 'input.txt' ), +\t$option_defs +); + +var_dump( $options['verbose'] ); +var_dump( $positionals );'''))), + ('Mix values, flags, and bundles', + '

    Values can be passed as --port 8080, --port=8080, -p 8080, or -p=8080. Boolean shorts can be bundled (-afv).

    ', + ('mix-shapes.php', php('''use WordPress\\CLI\\CLI; + +$option_defs = array( +\t'all' => array( 'a', false, false, 'Process everything' ), +\t'force' => array( 'f', false, false, 'Overwrite existing files' ), +\t'verbose' => array( 'v', false, false, 'Verbose output' ), +\t'output' => array( 'o', true, null, 'Output path' ), +\t'port' => array( 'p', true, '3000', 'Server port' ), +); + +$argv = array( '-afv', '--port=8080', '-o', '/tmp/result.txt', 'input.json' ); +list( $positionals, $options ) = CLI::parse_command_args_and_options( $argv, $option_defs ); + +print_r( array( 'positionals' => $positionals, 'options' => $options ) );'''))), + ('Validate required options', + '

    The parser fills in defaults but never enforces "required". Check for null after parsing — full control over the error message.

    ', + ('require-options.php', php('''use WordPress\\CLI\\CLI; + +$option_defs = array( +\t'site-url' => array( 'u', true, null, 'Public site URL (required)' ), +\t'site-path' => array( null, true, null, 'Target directory (required)' ), +); + +$argv = array( '--site-url', 'https://mysite.test' ); + +try { +\tlist( , $options ) = CLI::parse_command_args_and_options( $argv, $option_defs ); +\tforeach ( array( 'site-url', 'site-path' ) as $name ) { +\t\tif ( null === $options[ $name ] ) { +\t\t\tthrow new RuntimeException( "Missing required option --{$name}" ); +\t\t} +\t} +\techo "All good.\\n"; +} catch ( Exception $e ) { +\techo "error: " . $e->getMessage() . "\\n"; +}'''))), + ('Generate --help from definitions', + '

    Because each option carries its own description, you can render help text by walking the same definitions you parse with. No second source of truth.

    ', + ('help-text.php', php('''use WordPress\\CLI\\CLI; + +$option_defs = array( +\t'output' => array( 'o', true, null, 'Write result to FILE' ), +\t'force' => array( 'f', false, false, 'Overwrite existing files' ), +\t'verbose' => array( 'v', false, false, 'Verbose output' ), +\t'help' => array( 'h', false, false, 'Show this help and exit' ), +); + +function render_help( array $defs ) { +\techo "Usage: mytool [options] \\n\\nOptions:\\n"; +\tforeach ( $defs as $long => $def ) { +\t\tlist( $short, $has_value, $default, $desc ) = $def; +\t\t$flag = ( $short ? "-{$short}, " : ' ' ) . "--{$long}"; +\t\tif ( $has_value ) $flag .= '=VALUE'; +\t\techo sprintf( " %-28s %s\\n", $flag, $desc ); +\t} +} + +list( , $options ) = CLI::parse_command_args_and_options( array( '-h' ), $option_defs ); +if ( $options['help'] ) render_help( $option_defs );'''))), + ('Git-style subcommands', + '

    To build a tool with subcommands like mytool deploy, peel the first positional off argv, dispatch, and parse the rest with a per-command option set.

    ', + ('subcommands.php', php('''use WordPress\\CLI\\CLI; + +$commands = array( +\t'deploy' => array( +\t\t'env' => array( 'e', true, 'staging', 'Target environment' ), +\t\t'dry-run' => array( 'n', false, false, 'Preview without applying' ), +\t), +\t'rollback' => array( +\t\t'to' => array( 't', true, null, 'Revision to roll back to' ), +\t), +); + +function run( array $argv, array $commands ) { +\tif ( empty( $argv ) ) { +\t\techo "Usage: mytool [options]\\nCommands: " . implode( ', ', array_keys( $commands ) ) . "\\n"; +\t\treturn; +\t} +\t$command = array_shift( $argv ); +\tif ( ! isset( $commands[ $command ] ) ) { +\t\techo "Unknown command: {$command}\\n"; +\t\treturn; +\t} +\tlist( $positionals, $options ) = CLI::parse_command_args_and_options( $argv, $commands[ $command ] ); +\techo "command={$command}\\n"; +\techo "options: " . json_encode( $options ) . "\\n"; +\techo "positionals: " . json_encode( $positionals ) . "\\n"; +} + +run( array( 'deploy', '--env=production', '-n', 'web-01', 'web-02' ), $commands ); +echo "---\\n"; +run( array( 'rollback', '-t', 'abc123' ), $commands );'''))), + ])) + +# =========================================================================== +# Polyfill +# =========================================================================== +COMPONENTS.append(('polyfill', 'Polyfill', + 'PHP 8 string functions on PHP 7.2+, WordPress hook stubs, and translation/escaping passthroughs so toolkit code runs without WordPress.', + 'wp-php-toolkit/polyfill', + [ + ('Why this exists', + '

    A lot of WordPress-adjacent code wants to call esc_html(), __(), or apply_filters() without booting WordPress. The polyfill component provides minimal but real implementations so that code runs unchanged outside WordPress, and stays out of the way when WordPress is loaded (every function uses function_exists() guards).

    ', + None), + ('PHP 8 string functions on PHP 7.2', + '

    The polyfills define str_contains, str_starts_with, str_ends_with, and array_key_first only when missing.

    ', + ('php8-strings.php', php('''var_dump( str_starts_with( '/var/www/html', '/var' ) ); +var_dump( str_ends_with( 'image.png', '.png' ) ); +var_dump( str_contains( 'WordPress Toolkit', 'Toolkit' ) ); + +$first_key = array_key_first( array( 'alpha' => 1, 'beta' => 2 ) ); +echo "first key: {$first_key}\\n";'''))), + ('Escaping and translation stubs', + '

    Pass-through implementations let you write code that looks WordPressy and runs anywhere.

    ', + ('wp-stubs.php', php('''echo __( 'Hello, world' ) . "\\n"; +echo esc_html( '' ) . "\\n"; +echo esc_attr( 'a "quoted" value' ) . "\\n"; +echo esc_url( 'https://example.com/?a=1&b=2' ) . "\\n";'''))), + ('A simple filter chain', + '

    The hook system is a real implementation of the WordPress filter API: registered callbacks get applied in priority order, and each one transforms the running value.

    ', + ('filter-chain.php', php('''add_filter( 'sanitize_title', 'trim' ); +add_filter( 'sanitize_title', 'strtolower' ); +add_filter( 'sanitize_title', function ( $title ) { +\treturn preg_replace( '/\\s+/', '-', $title ); +} ); + +echo apply_filters( 'sanitize_title', ' My Post Title ' ) . "\\n";'''))), + ('Priority ordering and multi-arg passing', + '

    Lower priority numbers run first. The fourth argument to add_filter controls how many context values get passed to the callback.

    ', + ('priority-args.php', php('''add_filter( 'render_price', function ( $html, $price, $currency ) { +\treturn $html . " ({$currency} markup)"; +}, 30, 3 ); + +add_filter( 'render_price', function ( $html, $price ) { +\treturn "{$html}"; +}, 10, 2 ); + +add_filter( 'render_price', function ( $html, $price, $currency ) { +\tif ( 'EUR' === $currency ) return $html . ' EUR'; +\treturn $html . " {$currency}"; +}, 20, 3 ); + +echo apply_filters( 'render_price', '19.99', 19.99, 'EUR' ) . "\\n";'''))), + ('Hook-based extension points in standalone libraries', + '

    Use do_action and apply_filters as cheap extension points in your own code, without depending on WordPress.

    ', + ('library-hooks.php', php('''class ImportPipeline { +\tpublic function process( array $row ) { +\t\t$row = apply_filters( 'import_pipeline_normalize', $row ); +\t\tdo_action( 'import_pipeline_row_processed', $row ); +\t\treturn $row; +\t} +} + +add_filter( 'import_pipeline_normalize', function ( $row ) { +\t$row['email'] = strtolower( trim( $row['email'] ) ); +\treturn $row; +} ); + +$log = array(); +add_action( 'import_pipeline_row_processed', function ( $row ) use ( &$log ) { +\t$log[] = $row['email']; +} ); + +$pipeline = new ImportPipeline(); +$pipeline->process( array( 'email' => ' USER@EXAMPLE.COM ' ) ); +$pipeline->process( array( 'email' => 'OTHER@example.com' ) ); + +print_r( $log );'''))), + ])) + +# =========================================================================== +# 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', + '

    Blueprints can create a new WordPress install (download core, set up the database, apply steps) or apply to an existing site. Creating a fresh site needs filesystem access this in-browser runtime doesn\'t have, so the snippets focus on APPLY_TO_EXISTING_SITE.

    ', + None), + ('Configure a runner for an existing site', + '

    RunnerConfiguration is a fluent builder. The minimum: target site root, target site URL, execution mode.

    ', + ('configure.php', php('''use WordPress\\Blueprints\\Runner; +use WordPress\\Blueprints\\RunnerConfiguration; + +$config = ( new RunnerConfiguration() ) +\t->set_execution_mode( Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) +\t->set_target_site_root( '/wordpress' ) +\t->set_target_site_url( 'http://playground.test/' ); + +echo "mode: " . $config->get_execution_mode() . "\\n"; +echo "root: " . $config->get_target_site_root() . "\\n"; +echo "url: " . $config->get_target_site_url() . "\\n";'''))), + ('The Blueprint JSON shape', + '

    A blueprint is a JSON document with a version field and a steps array. Each step has a "step" discriminator and step-specific fields. This is the same shape used by WordPress Playground.

    ' + '
    {\n  "version": 2,\n  "steps": [\n    { "step": "setSiteOptions",\n      "options": {\n        "blogname": "Demo Site",\n        "permalink_structure": "/%postname%/"\n      } },\n    { "step": "installPlugin",\n      "pluginData": "https://downloads.wordpress.org/plugin/gutenberg.zip" },\n    { "step": "activatePlugin",\n      "plugin": "gutenberg/gutenberg.php" }\n  ]\n}
    ', + None), + ])) + +# =========================================================================== +# ToolkitCodingStandards +# =========================================================================== +COMPONENTS.append(('coding-standards', 'ToolkitCodingStandards', + 'PHP_CodeSniffer sniffs used by this project: enforce Yoda comparisons, ban the short ternary.', + 'wp-php-toolkit/toolkit-coding-standards', + [ + ('Reference the standard from your phpcs.xml', + '

    The component is a phpcs ruleset, so there\'s no runtime code to demo. Activate both sniffs at once by referencing WordPressToolkitCodingStandards:

    ' + '
    <?xml version="1.0"?>\n<ruleset name="My Project">\n  <file>src/</file>\n\n  <!-- Activate both toolkit sniffs -->\n  <rule ref="WordPressToolkitCodingStandards"/>\n\n  <!-- Or pick them individually -->\n  <!-- <rule ref="WordPressToolkitCodingStandards.PHP.EnforceYodaComparison"/> -->\n  <!-- <rule ref="WordPressToolkitCodingStandards.PHP.DisallowShortTernary"/> -->\n</ruleset>
    ' + '

    Then run phpcs and phpcbf the usual way:

    ' + '
    vendor/bin/phpcs --standard=phpcs.xml .\nvendor/bin/phpcbf --standard=phpcs.xml .
    ', + None), + ('EnforceYodaComparison: catches accidental assignment', + '

    Yoda comparisons (true === $x) make typo-induced assignments into syntax errors:

    ' + '
    // Bug: single = inside a condition. Always truthy, mutates $status.\nif ( $status = \'published\' ) {\n    publish_post( $post );\n}\n\n// Yoda style: writing this typo would be a parse error.\nif ( \'published\' === $status ) {\n    publish_post( $post );\n}
    ' + '

    The sniff covers ===, !==, ==, and !=, and stays quiet when both sides are dynamic.

    ', + None), + ('Why ban the short ternary', + '

    The short ternary ($a ?: $b) is often confused with the null-coalescing operator ($a ?? $b). They differ on falsy-but-not-null values: 0 ?: \'fallback\' returns \'fallback\', but 0 ?? \'fallback\' returns 0. The sniff bans ?: entirely so reviewers don\'t have to relitigate this on every PR.

    ', + None), + ])) diff --git a/bin/build-docs.py b/bin/build-docs.py index 57241de1a..aab009a38 100755 --- a/bin/build-docs.py +++ b/bin/build-docs.py @@ -1,24 +1,17 @@ #!/usr/bin/env python3 """ -Generates docs//index.html for every component listed below from -a single template, plus the docs/index.html landing page. - -Each component entry is a tuple of: - (slug, title, lede, install_pkg, sections) - -`sections` is a list of (heading, body_html, snippet_or_none) — body_html may -contain HTML; snippet is a (filename, php_code) tuple or None. - -The PHP snippets here are derived from each component's own README. They run -inside WordPress Playground via ``, -which loads docs/assets/php-toolkit.zip and the toolkit's vendor/autoload.php. +Generates docs//index.html for every component plus the docs/index.html +landing page. The component catalog lives in bin/_docs_components.py so that +content and orchestration stay separate. """ import os import re import sys from html import escape as h -from textwrap import dedent + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _docs_components import COMPONENTS DOCS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'docs') @@ -53,10 +46,9 @@ def snippet_block(name, code): - # -\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.

    ', - ('create-zip.php', php('''use WordPress\\ByteStream\\MemoryPipe; -use WordPress\\ByteStream\\WriteStream\\FileWriteStream; -use WordPress\\Zip\\FileEntry; -use WordPress\\Zip\\ZipDecoder; -use WordPress\\Zip\\ZipEncoder; - -$path = tempnam( sys_get_temp_dir(), 'demo' ) . '.zip'; -$out = FileWriteStream::from_path( $path, 'truncate' ); -$enc = new ZipEncoder( $out ); - -foreach ( array( -\t'readme.txt' => 'Hello from the toolkit.', -\t'data/hello.json' => json_encode( array( 'ok' => true ) ), -) as $name => $body ) { -\t$enc->append_file( new FileEntry( array( -\t\t'path' => $name, -\t\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, -\t\t'body_reader' => new MemoryPipe( $body ), -\t) ) ); -} -$enc->close(); -$out->close_writing(); - -$bytes = file_get_contents( $path ); -printf( "Wrote %d bytes, %d entries.\\n", strlen( $bytes ), substr_count( $bytes, "PK\\x01\\x02" ) );'''))), - ('Read entries through a filesystem', - '

    ZipFilesystem implements the toolkit\'s Filesystem interface, so you can ls(), is_file(), and get_contents() as if the archive were a directory tree.

    ', - ('read-zip.php', php('''use WordPress\\ByteStream\\MemoryPipe; -use WordPress\\ByteStream\\ReadStream\\FileReadStream; -use WordPress\\ByteStream\\WriteStream\\FileWriteStream; -use WordPress\\Zip\\FileEntry; -use WordPress\\Zip\\ZipDecoder; -use WordPress\\Zip\\ZipEncoder; -use WordPress\\Zip\\ZipFilesystem; - -// Build an archive on the fly so the example is self-contained. -$path = tempnam( sys_get_temp_dir(), 'demo' ) . '.zip'; -$out = FileWriteStream::from_path( $path, 'truncate' ); -$enc = new ZipEncoder( $out ); -foreach ( array( -\t'mimetype' => 'application/epub+zip', -\t'EPUB/package.opf' => '', -) as $name => $body ) { -\t$enc->append_file( new FileEntry( array( -\t\t'path' => $name, -\t\t'compression_method' => ZipDecoder::COMPRESSION_NONE, -\t\t'body_reader' => new MemoryPipe( $body ), -\t) ) ); -} -$enc->close(); -$out->close_writing(); - -$zip = ZipFilesystem::create( FileReadStream::from_path( $path ) ); -foreach ( $zip->ls() as $entry ) { -\techo $zip->is_dir( $entry ) -\t\t? "[dir] {$entry}\\n" -\t\t: "{$entry}: " . $zip->get_contents( $entry ) . "\\n"; -}'''))), - ('Stream a large file out of an archive', - '

    For multi-megabyte entries inside an archive, use open_read_stream() instead of loading the whole file. The decoder inflates as you pull.

    ', - ('stream-large-entry.php', php('''use WordPress\\ByteStream\\MemoryPipe; -use WordPress\\ByteStream\\ReadStream\\FileReadStream; -use WordPress\\ByteStream\\WriteStream\\FileWriteStream; -use WordPress\\Zip\\FileEntry; -use WordPress\\Zip\\ZipDecoder; -use WordPress\\Zip\\ZipEncoder; -use WordPress\\Zip\\ZipFilesystem; - -$path = tempnam( sys_get_temp_dir(), 'demo' ) . '.zip'; -$out = FileWriteStream::from_path( $path, 'truncate' ); -$enc = new ZipEncoder( $out ); -$enc->append_file( new FileEntry( array( -\t'path' => 'big.csv', -\t'compression_method' => ZipDecoder::COMPRESSION_DEFLATE, -\t'body_reader' => new MemoryPipe( str_repeat( "id,value\\n1,foo\\n2,bar\\n", 200 ) ), -) ) ); -$enc->close(); -$out->close_writing(); - -$zip = ZipFilesystem::create( FileReadStream::from_path( $path ) ); -$stream = $zip->open_read_stream( 'big.csv' ); -$lines = 0; -while ( ! $stream->reached_end_of_data() ) { -\t$n = $stream->pull( 4096 ); -\tif ( $n <= 0 ) break; -\t$lines += substr_count( $stream->consume( $n ), "\\n" ); -} -echo "Streamed {$lines} lines.\\n";'''))), - ])) - -# ---------- ByteStream ---------- -COMPONENTS.append(('bytestream', 'ByteStream', - 'Composable streaming primitives for reading, writing, and transforming byte data. Pull-based, peek-friendly, and zero-copy where it matters.', - 'wp-php-toolkit/bytestream', - [ - ('The model', - '

    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.

    ', - ('read-file.php', php('''use WordPress\\ByteStream\\ReadStream\\FileReadStream; - -// Make a sample file inside the snippet so it is self-contained. -$path = tempnam( sys_get_temp_dir(), 'sample' ); -file_put_contents( $path, str_repeat( "line of text\\n", 50 ) ); - -$reader = FileReadStream::from_path( $path ); -$total = 0; -while ( ! $reader->reached_end_of_data() ) { -\t$n = $reader->pull( 64 ); -\tif ( $n <= 0 ) break; -\t$total += strlen( $reader->consume( $n ) ); -} -$reader->close_reading(); -echo "Read {$total} bytes.\\n";'''))), - ('Memory pipes', - '

    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.

    ', - ('memory-pipe.php', php('''use WordPress\\ByteStream\\MemoryPipe; - -$pipe = new MemoryPipe(); -$pipe->append_bytes( "first line\\n" ); -$pipe->append_bytes( "second line\\n" ); -$pipe->close_writing(); - -while ( ! $pipe->reached_end_of_data() ) { -\t$n = $pipe->pull( 1024 ); -\tif ( $n <= 0 ) break; -\techo "chunk: " . $pipe->consume( $n ); -}'''))), - ('Transform on the fly', - '

    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.

    ', - ('in-memory.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; - -$fs = InMemoryFilesystem::create(); -$fs->mkdir( '/src/components', array( 'recursive' => true ) ); -$fs->put_contents( '/src/components/button.php', 'put_contents( '/src/components/form.php', 'ls( '/src/components' ) );'''))), - ('Local disk', - '

    LocalFilesystem::create($root) chroots all paths to $root. The forward-slash convention is enforced even on Windows.

    ', - ('local.php', php('''use WordPress\\Filesystem\\LocalFilesystem; - -$root = sys_get_temp_dir() . '/toolkit-demo'; -$fs = LocalFilesystem::create( $root ); - -$fs->put_contents( '/hello.txt', "Hi!\\n" ); -echo $fs->get_contents( '/hello.txt' ); -echo "exists? " . ( $fs->exists( '/hello.txt' ) ? 'yes' : 'no' ) . "\\n";'''))), - ('SQLite-backed', - '

    Everything lives in a single SQLite file. Convenient for portable scratch storage that survives a process boundary.

    ', - ('sqlite.php', php('''use WordPress\\Filesystem\\SQLiteFilesystem; - -$fs = SQLiteFilesystem::create( ':memory:' ); -$fs->put_contents( '/notes.md', "# Hello\\n\\nFrom SQLite." ); -echo $fs->get_contents( '/notes.md' );'''))), - ('Walk a tree', - '

    The interface includes a recursive walker so you can iterate every file regardless of backend.

    ', - ('walk.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; - -$fs = InMemoryFilesystem::create(); -foreach ( array( -\t'/a.txt' => 'A', -\t'/dir/b.txt' => 'B', -\t'/dir/sub/c.txt' => 'C', -) as $path => $body ) { -\t$fs->mkdir( dirname( $path ), array( 'recursive' => true ) ); -\t$fs->put_contents( $path, $body ); -} - -$walker = function ( $dir ) use ( $fs, &$walker ) { -\tforeach ( $fs->ls( $dir ) as $name ) { -\t\t$full = rtrim( $dir, '/' ) . '/' . $name; -\t\tif ( $fs->is_dir( $full ) ) $walker( $full ); -\t\telse echo "{$full}\\n"; -\t} -}; -$walker( '/' );'''))), - ])) - -# ---------- BlockParser ---------- -COMPONENTS.append(('blockparser', 'BlockParser', - 'WordPress core\'s block parser as a standalone library. Same parser, no WP dependency.', - 'wp-php-toolkit/blockparser', - [ - ('Parse block markup', - '

    Pass the parser any HTML containing block delimiters and get back a structured array — blockName, attrs, innerBlocks, innerHTML, innerContent.

    ', - ('parse-blocks.php', php('''$document = "\\n

    Welcome

    \\n\\n\\n\\n

    Hello from the block editor.

    \\n"; - -$blocks = ( new WP_Block_Parser() )->parse( $document ); -foreach ( $blocks as $block ) { -\tif ( $block['blockName'] ) { -\t\techo "{$block['blockName']}: " . trim( strip_tags( $block['innerHTML'] ) ) . "\\n"; -\t} -}'''))), - ('Self-closing blocks', - '

    Void blocks like core/spacer end with /-->. They have no inner HTML, just attributes.

    ', - ('void-blocks.php', php('''$blocks = ( new WP_Block_Parser() )->parse( -\t'' -); -print_r( $blocks[0] );'''))), - ('Walk nested blocks', - '

    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).

    ', - ('blocks-to-md.php', php('''use WordPress\\Markdown\\MarkdownConsumer; -use WordPress\\Markdown\\MarkdownProducer; - -$source = "## Setup\\n\\n1. Install\\n2. Configure\\n3. Profit"; -$blocks = ( new MarkdownConsumer( $source ) )->consume()->get_block_markup(); -$round = new MarkdownProducer( $blocks ); -echo $round->produce();'''))), - ('Frontmatter', - '

    YAML frontmatter is parsed and exposed as metadata so the block markup stays clean.

    ', - ('frontmatter.php', php('''use WordPress\\Markdown\\MarkdownConsumer; - -$markdown = <<consume(); -print_r( $result->get_all_metadata() ); -echo "\\n--- block markup ---\\n"; -echo $result->get_block_markup();'''))), - ])) - -# ---------- XML ---------- -COMPONENTS.append(('xml', 'XML', - 'Streaming XML processor without libxml2. Modify attributes, walk namespaces, scan large documents without loading them into memory.', - 'wp-php-toolkit/xml', - [ - ('Read and rewrite an attribute', - '

    The XMLProcessor mirrors the HTML tag processor — find a tag, read or set attributes, get the modified document back.

    ', - ('rewrite-attr.php', php('''use WordPress\\XML\\XMLProcessor; - -$xml = 'PHP Internals'; -$p = XMLProcessor::create_from_string( $xml ); - -if ( $p->next_tag( 'book' ) ) { -\techo "before: " . $p->get_attribute( '', 'price' ) . "\\n"; -\t$p->set_attribute( '', 'price', '24.99' ); -} -echo $p->get_updated_xml();'''))), - ('Namespaces are first-class', - '

    Methods take a namespace URI as the first argument, never a prefix. The processor resolves prefixes itself.

    ', - ('namespaces.php', php('''use WordPress\\XML\\XMLProcessor; - -$xml = '' -\t. 'Content'; - -$p = XMLProcessor::create_from_string( $xml ); -$ns = 'http://wordpress.org/export/1.2/'; -if ( $p->next_tag( array( $ns, 'post' ) ) ) { -\techo "tag: " . $p->get_tag_local_name() . "\\n"; -\techo "status: " . $p->get_attribute( $ns, 'status' ) . "\\n"; -\t$p->set_attribute( $ns, 'status', 'published' ); -} -echo $p->get_updated_xml();'''))), - ])) - -# ---------- Encoding ---------- -COMPONENTS.append(('encoding', 'Encoding', - 'Pure-PHP UTF-8 validation and scrubbing. Detects malformed bytes, replaces them per the Unicode maximal-subpart algorithm, and works without mbstring.', - 'wp-php-toolkit/encoding', - [ - ('Validate', None, - ('validate.php', php('''use function WordPress\\Encoding\\wp_is_valid_utf8; - -var_dump( wp_is_valid_utf8( 'plain ASCII' ) ); // true -var_dump( wp_is_valid_utf8( "Pencil: \\xE2\\x9C\\x8F" ) ); // true -var_dump( wp_is_valid_utf8( "stray \\xC0 byte" ) ); // false -var_dump( wp_is_valid_utf8( "\\xC1\\xBF" ) ); // false (overlong)'''))), - ('Scrub invalid bytes', '

    Replace each maximal subpart with U+FFFD.

    ', - ('scrub.php', php('''use function WordPress\\Encoding\\wp_scrub_utf8; - -echo wp_scrub_utf8( "caf\\xC0 latte" ) . "\\n"; // caf? latte -echo wp_scrub_utf8( ".\\xE2\\x8C." ) . "\\n"; // .?. (incomplete) -echo wp_scrub_utf8( ".\\xC1\\xBF." ) . "\\n"; // .??. (two subparts)'''))), - ('Detect noncharacters', '

    Code points like U+FFFE that should never appear in interchange.

    ', - ('noncharacters.php', php('''use function WordPress\\Encoding\\wp_has_noncharacters; - -var_dump( wp_has_noncharacters( "Plain text" ) ); // false -var_dump( wp_has_noncharacters( "\\xEF\\xBF\\xBE" ) ); // true (U+FFFE)'''))), - ])) - -# ---------- Polyfill ---------- -COMPONENTS.append(('polyfill', 'Polyfill', - 'PHP 8 string functions on PHP 7.2+, WordPress hook stubs, and translation/escaping passthroughs so toolkit code runs without WordPress.', - 'wp-php-toolkit/polyfill', - [ - ('PHP 8 strings on 7.2', - '

    Polyfills are loaded automatically through Composer\'s autoload.files. They define functions only when missing, so they\'re safe to use alongside PHP 8.

    ', - ('php8-strings.php', php('''var_dump( str_starts_with( '/var/www', '/var' ) ); -var_dump( str_ends_with( 'image.png', '.png' ) ); -var_dump( str_contains( 'WordPress Toolkit', 'Toolkit' ) );'''))), - ('WordPress hooks without WordPress', - '

    A minimal but real implementation of add_filter, apply_filters, and the action equivalents — priorities and all.

    ', - ('hooks.php', php('''add_filter( 'the_title', 'strtoupper' ); -add_filter( 'the_title', function ( $title ) { -\treturn '> ' . $title; -}, 20 ); - -echo apply_filters( 'the_title', 'hello world' ) . "\\n";'''))), - ])) - -# ---------- CLI ---------- -COMPONENTS.append(('cli', 'CLI', - 'POSIX-style argument parser. Long options, short bundles, inline values, positional args — one static call.', - 'wp-php-toolkit/cli', - [ - ('Parse argv', - '

    Define options as a four-tuple of [ short, has_value, default, description ], pass argv, get back positionals and an option map.

    ', - ('parse-args.php', php('''use WordPress\\CLI\\CLI; - -$option_defs = array( -\t'output' => array( 'o', true, null, 'Output file path' ), -\t'force' => array( 'f', false, false, 'Overwrite existing files' ), -\t'verbose' => array( 'v', false, false, 'Verbose output' ), -); - -$argv = array( '--output=/tmp/result.txt', '-fv', 'input.json' ); -list( $positionals, $options ) = CLI::parse_command_args_and_options( $argv, $option_defs ); - -print_r( array( -\t'positionals' => $positionals, -\t'options' => $options, -) );'''))), - ])) - -# ---------- Git ---------- -COMPONENTS.append(('git', 'Git', - 'A pure-PHP Git client and server. Commits, branches, diffs, HTTP push/pull — all without shelling out to git.', - 'wp-php-toolkit/git', - [ - ('Commit files in memory', - '

    The repository builds the blob, tree, and commit objects for you. Backed by any Filesystem, including InMemoryFilesystem for ephemeral repos.

    ', - ('commit.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; -use WordPress\\Git\\GitRepository; - -$repo = new GitRepository( InMemoryFilesystem::create() ); - -$oid = $repo->commit( array( -\t'updates' => array( -\t\t'README.md' => '# My Project', -\t\t'src/hello-world.php' => 'read_object_by_path( '/README.md' )->consume_all();'''))), - ('Read objects by hash', - '

    Every Git object is identified by its SHA-1. Store a blob, get the hash back, read it later.

    ', - ('objects.php', php('''use WordPress\\Filesystem\\InMemoryFilesystem; -use WordPress\\Git\\GitRepository; - -$repo = new GitRepository( InMemoryFilesystem::create() ); -$blob = $repo->add_object( 'blob', 'Hello, world!' ); -echo "oid: {$blob}\\n"; - -$reader = $repo->read_object( $blob ); -$reader->pull( 8096 ); -echo $reader->peek( 8096 ) . "\\n";'''))), - ])) - -# ---------- Merge ---------- -COMPONENTS.append(('merge', 'Merge', - 'Three-way merge and diff. Pluggable differ + merger + optional validator.', - 'wp-php-toolkit/merge', - [ - ('Merge two branches', - '

    Give it a base, branch A, and branch B. Get a merge result with conflicts (if any) and the merged content.

    ', - ('three-way.php', php('''use WordPress\\Merge\\Diff\\LineDiffer; -use WordPress\\Merge\\Merge\\LineMerger; -use WordPress\\Merge\\MergeStrategy; - -$strategy = new MergeStrategy( new LineDiffer(), new LineMerger() ); - -$result = $strategy->merge( -\t"Line 1\\nLine 2\\nLine 3\\n", -\t"Line 1\\nLine 2 modified\\nLine 3\\n", -\t"Line 1\\nLine 2\\nLine 3\\nLine 4\\n" -); - -echo $result->get_merged_content();'''))), - ('Inspect a diff', - '

    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.

    ', - ('apply-step.php', php('''use WordPress\\Blueprints\\Runner; -use WordPress\\Blueprints\\RunnerConfiguration; - -$config = ( new RunnerConfiguration() ) -\t->set_execution_mode( Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) -\t->set_target_site_root( '/wordpress' ) -\t->set_target_site_url( 'http://playground.test/' ); - -echo "Configured runner for: " . $config->get_target_site_root() . "\\n"; -echo "Mode: " . $config->get_execution_mode() . "\\n";'''))), - ])) - -# ---------- DataLiberation ---------- -COMPONENTS.append(('dataliberation', 'DataLiberation', - 'Streaming WordPress import/export. WXR, SQL, block markup — without loading whole datasets into memory.', - 'wp-php-toolkit/data-liberation', - [ - ('Write a WXR export', - '

    Feed entities to WXRWriter in logical order: post first, then meta/terms/comments belonging to it.

    ', - ('wxr-writer.php', php('''use WordPress\\ByteStream\\MemoryPipe; -use WordPress\\DataLiberation\\EntityWriter\\WXRWriter; -use WordPress\\DataLiberation\\ImportEntity; - -$output = new MemoryPipe(); -$writer = new WXRWriter( $output ); - -$writer->append_entity( new ImportEntity( 'post', array( -\t'post_title' => 'Hello World', -\t'post_date' => '2024-01-15', -\t'guid' => 'https://example.com/?p=1', -\t'content' => '

    Welcome to my site.

    ', -\t'post_id' => '1', -\t'post_name' => 'hello-world', -\t'status' => 'publish', -\t'post_type' => 'post', -) ) ); - -$writer->finalize(); -$writer->close_writing(); -$output->close_writing(); - -echo $output->consume_all();'''))), - ])) - -# ---------- ToolkitCodingStandards ---------- -COMPONENTS.append(('coding-standards', 'ToolkitCodingStandards', - 'PHP_CodeSniffer sniffs used by this project: enforce Yoda comparisons, ban the short ternary.', - 'wp-php-toolkit/toolkit-coding-standards', - [ - ('How to use it', - '

    This component is a phpcs ruleset, not runtime code, so there\'s nothing to demo in Playground. Reference the standard from your phpcs.xml:

    ' - '
    <ruleset>\n  <rule ref="WordPressToolkitCodingStandards"/>\n</ruleset>
    ' - '

    See the README for individual sniff selection and example fixes.

    ', - None), - ])) - - def render_index(): cards = [] for slug, title, lede, _, _ in COMPONENTS: - # one-line description: strip HTML, take first sentence, cap length clean = re.sub(r'<[^>]+>', '', lede) first = clean.split('.')[0] if len(first) > 110: @@ -861,11 +175,9 @@ def render_index(): 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) diff --git a/docs/blockparser/index.html b/docs/blockparser/index.html index bbddfabca..2064233cf 100644 --- a/docs/blockparser/index.html +++ b/docs/blockparser/index.html @@ -4,7 +4,7 @@ BlockParser — PHP Toolkit - + @@ -33,73 +33,206 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • BlockParser

    -

    WordPress core's block parser as a standalone library. Same parser, no WP dependency.

    +

    WordPress core's block parser, packaged as a standalone library. Turn block markup into a structured tree, lint posts for common authoring mistakes, and migrate attributes between block versions — all without booting WordPress.

    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.

    - +

    What you get back

    +

    WP_Block_Parser::parse() returns an array of blocks. Each block is an associative array with five keys: blockName, attrs, innerBlocks, innerHTML, and innerContent.

    innerHTML is the HTML inside the block with inner blocks stripped out. innerContent is the interleaved version: an array of HTML strings with null placeholders marking where each inner block belongs.

    Footgun: freeform HTML between blocks shows up as a block with blockName === null. Walkers that key off blockName need to handle the null case.

    +

    Parse a document

    +

    The simplest possible use. Pass a string, get back a tree.

    + -

    Self-closing blocks

    -

    Void blocks like core/spacer end with /-->. They have no inner HTML, just attributes.

    - +

    Count every block type in a post

    +

    The first thing most plugin authors actually want: a histogram. Combine recursion with blockName to handle core/columns, core/group, and other containers.

    + -

    Walk nested blocks

    -

    Inner blocks recurse with the same shape, so a depth-first walk is a small recursive function.

    - +

    Lint headings for hierarchy mistakes

    +

    "Don't skip from h2 to h4" is a real accessibility rule. Walk every core/heading, look at its level attribute (default 2), and warn whenever the level jumps by more than one.

    + + +

    Find all instances of a custom block

    +

    Useful when auditing an export for a block your plugin owns: every my-plugin/testimonial, with its attributes and inner content.

    + + + +

    Migrate attributes from an old block version

    +

    Block schemas evolve. A common migration: rename textColor to color, or split a combined attribute. Walk the tree, detect the old shape, write a new block array.

    + + + +

    Detect blocks with stale embed URLs

    +

    A real-world scrub: find every core/embed whose URL points at a domain you've retired. Useful when auditing a multi-thousand-post export.

    + +

    Full API reference: BlockParser README.

    diff --git a/docs/blueprints/index.html b/docs/blueprints/index.html index 803857652..fcef8f482 100644 --- a/docs/blueprints/index.html +++ b/docs/blueprints/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -52,10 +52,10 @@

    Blueprints

    composer require wp-php-toolkit/blueprints

    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.

    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.

    -

    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.

    - +

    Blueprints can create a new WordPress install (download core, set up the database, apply steps) or apply to an existing site. Creating a fresh site needs filesystem access this in-browser runtime doesn't have, so the snippets focus on APPLY_TO_EXISTING_SITE.

    +

    Configure a runner for an existing site

    +

    RunnerConfiguration is a fluent builder. The minimum: target site root, target site URL, execution mode.

    + +

    The Blueprint JSON shape

    +

    A blueprint is a JSON document with a version field and a steps array. Each step has a "step" discriminator and step-specific fields. This is the same shape used by WordPress Playground.

    {
    +  "version": 2,
    +  "steps": [
    +    { "step": "setSiteOptions",
    +      "options": {
    +        "blogname": "Demo Site",
    +        "permalink_structure": "/%postname%/"
    +      } },
    +    { "step": "installPlugin",
    +      "pluginData": "https://downloads.wordpress.org/plugin/gutenberg.zip" },
    +    { "step": "activatePlugin",
    +      "plugin": "gutenberg/gutenberg.php" }
    +  ]
    +}

    Full API reference: Blueprints README.

    diff --git a/docs/bytestream/index.html b/docs/bytestream/index.html index 8ba01c1bf..7842ccac7 100644 --- a/docs/bytestream/index.html +++ b/docs/bytestream/index.html @@ -4,7 +4,7 @@ ByteStream — PHP Toolkit - + @@ -33,52 +33,51 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • ByteStream

    -

    Composable streaming primitives for reading, writing, and transforming byte data. Pull-based, peek-friendly, and zero-copy where it matters.

    +

    Composable streaming primitives for reading, writing, transforming, hashing, and compressing byte data. Pull/peek/consume semantics let parsers backtrack without copying, and deflate, inflate, and checksum filters snap together like Lego.

    composer require wp-php-toolkit/bytestream

    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.

    -

    The model

    -

    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.

    +

    Why this exists

    +

    PHP's native streams are powerful but inconsistent. fread on a socket may return short reads with no warning; stream_filter_append is awkward to compose; gzopen works only on files. The ByteStream component normalizes all of these behind one tiny interface — pull / peek / consume — so a parser, a hash function, and a deflate filter all see the same shape.

    The split between pull (buffer up to N bytes) and consume (advance past N bytes) is the secret. Parsers can peek ahead to detect a record boundary and decide whether to consume, without copying or allocating.

    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.

    - +

    The canonical loop. pull() tells you how many bytes are buffered; consume() reads them and advances. The buffer never grows beyond the chunk size you ask for.

    + -

    Memory pipes

    -

    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.

    +

    MemoryPipe as write-then-read buffer

    +

    MemoryPipe is bidirectional: you append_bytes() as a writer and pull/consume as a reader. Easiest way to wire one component's output into another's input.

    Gotcha: a producer must call close_writing() when done — otherwise the consumer eventually throws NotEnoughDataException instead of seeing EOF.

    -

    Transform on the fly

    -

    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.

    - +

    Compress on the way in, decompress on the way out

    +

    Wrap a stream in DeflateReadStream to get compressed bytes out; wrap it in InflateReadStream to get decompressed bytes out. Both are full ByteReadStream implementations, so they nest into anything else that takes a stream.

    + + +

    Line-by-line reads from a chunked source

    +

    Reading text by line means handling chunk boundaries that fall mid-line. Keep the trailing partial line and prepend it to the next pull. The rest of the loop pretends the data was always whole.

    + + + +

    Limit a stream to a fixed window

    +

    LimitedByteReadStream exposes only the next N bytes of an underlying stream as if those were the entire stream. This is how the ZIP decoder hands you the body of one entry without letting you read into the next.

    + +

    Full API reference: ByteStream README.

    diff --git a/docs/cli/index.html b/docs/cli/index.html index 7189c1ae9..e0c21f559 100644 --- a/docs/cli/index.html +++ b/docs/cli/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,9 +51,33 @@

    CLI

    POSIX-style argument parser. Long options, short bundles, inline values, positional args — one static call.

    composer require wp-php-toolkit/cli

    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 argv

    -

    Define options as a four-tuple of [ short, has_value, default, description ], pass argv, get back positionals and an option map.

    - +

    Why this exists

    +

    Real CLI tools in PHP usually mean either pulling in symfony/console (and 30+ transitive packages) or hand-rolling argv parsing that breaks the first time someone writes -vvv or --port=8080. The toolkit's CLI class is one static method, no dependencies, and handles the POSIX shapes you actually see.

    +

    Parse a single flag

    +

    The smallest useful invocation: one boolean flag, one positional. Each option is a four-tuple of [ short, has_value, default, description ].

    + + + +

    Mix values, flags, and bundles

    +

    Values can be passed as --port 8080, --port=8080, -p 8080, or -p=8080. Boolean shorts can be bundled (-afv).

    + + +

    Validate required options

    +

    The parser fills in defaults but never enforces "required". Check for null after parsing — full control over the error message.

    + + + +

    Generate --help from definitions

    +

    Because each option carries its own description, you can render help text by walking the same definitions you parse with. No second source of truth.

    + + + +

    Git-style subcommands

    +

    To build a tool with subcommands like mytool deploy, peel the first positional off argv, dispatch, and parse the rest with a per-command option set.

    + +

    Full API reference: CLI README.

    diff --git a/docs/coding-standards/index.html b/docs/coding-standards/index.html index 86b99a029..61c2b38ad 100644 --- a/docs/coding-standards/index.html +++ b/docs/coding-standards/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,10 +51,31 @@

    ToolkitCodingStandards

    PHP_CodeSniffer sniffs used by this project: enforce Yoda comparisons, ban the short ternary.

    composer require wp-php-toolkit/toolkit-coding-standards

    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.

    -

    How to use it

    -

    This component is a phpcs ruleset, not runtime code, so there's nothing to demo in Playground. Reference the standard from your phpcs.xml:

    <ruleset>
    +		

    Reference the standard from your phpcs.xml

    +

    The component is a phpcs ruleset, so there's no runtime code to demo. Activate both sniffs at once by referencing WordPressToolkitCodingStandards:

    <?xml version="1.0"?>
    +<ruleset name="My Project">
    +  <file>src/</file>
    +
    +  <!-- Activate both toolkit sniffs -->
       <rule ref="WordPressToolkitCodingStandards"/>
    -</ruleset>

    See the README for individual sniff selection and example fixes.

    + + <!-- Or pick them individually --> + <!-- <rule ref="WordPressToolkitCodingStandards.PHP.EnforceYodaComparison"/> --> + <!-- <rule ref="WordPressToolkitCodingStandards.PHP.DisallowShortTernary"/> --> +</ruleset>

    Then run phpcs and phpcbf the usual way:

    vendor/bin/phpcs --standard=phpcs.xml .
    +vendor/bin/phpcbf --standard=phpcs.xml .
    +

    EnforceYodaComparison: catches accidental assignment

    +

    Yoda comparisons (true === $x) make typo-induced assignments into syntax errors:

    // Bug: single = inside a condition. Always truthy, mutates $status.
    +if ( $status = 'published' ) {
    +    publish_post( $post );
    +}
    +
    +// Yoda style: writing this typo would be a parse error.
    +if ( 'published' === $status ) {
    +    publish_post( $post );
    +}

    The sniff covers ===, !==, ==, and !=, and stays quiet when both sides are dynamic.

    +

    Why ban the short ternary

    +

    The short ternary ($a ?: $b) is often confused with the null-coalescing operator ($a ?? $b). They differ on falsy-but-not-null values: 0 ?: 'fallback' returns 'fallback', but 0 ?? 'fallback' returns 0. The sniff bans ?: entirely so reviewers don't have to relitigate this on every PR.

    Full API reference: ToolkitCodingStandards README.

    diff --git a/docs/corsproxy/index.html b/docs/corsproxy/index.html index e6ad36797..ccd02392a 100644 --- a/docs/corsproxy/index.html +++ b/docs/corsproxy/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,8 +51,115 @@

    CORSProxy

    A small PHP CORS proxy intended for browser-side code that needs to reach servers without CORS headers.

    composer require wp-php-toolkit/corsproxy

    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.

    -

    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.

    +

    Run the proxy locally

    +

    Run on your machine: the proxy needs to listen on a port. Start PHP's built-in server and request any HTTPS URL through it.

    PLAYGROUND_CORS_PROXY_DISABLE_RATE_LIMIT=1 \
    +  php -S 127.0.0.1:5263 vendor/wp-php-toolkit/corsproxy/cors-proxy.php
    +
    +# In another terminal:
    +curl -s "http://127.0.0.1:5263/cors-proxy.php/https://api.github.com/repos/WordPress/php-toolkit" | head
    +
    +

    Production rate limiting

    +

    Drop a cors-proxy-config.php next to cors-proxy.php. The proxy refuses to boot without one — that is the point.

    This example uses a per-IP token bucket stored on disk. Replace with Redis / memcached for multi-host deployments.

    + + + +

    Allowlist upstream hosts

    +

    Out of the box the proxy will fetch any public URL. Most real deployments want a fixed list of upstreams — GitHub, Packagist, wp.org.

    + + + +

    Browser-side fetch through the proxy

    +

    Once deployed, the client side is just fetch() with the proxy URL. Drop this into any HTML page.

    const PROXY = "https://cors.example.com/cors-proxy.php";
    +
    +async function viaProxy(url, init = {}) {
    +  const res = await fetch(`${PROXY}/${url}`, {
    +    ...init,
    +    headers: {
    +      ...(init.headers || {}),
    +      "X-Cors-Proxy-Allowed-Request-Headers": "Authorization",
    +    },
    +  });
    +  if (!res.ok) throw new Error(`Proxy returned ${res.status}`);
    +  return res;
    +}
    +
    +const repo = await viaProxy("https://api.github.com/repos/WordPress/php-toolkit").then(r => r.json());
    +console.log(repo.full_name, repo.stargazers_count);
    +
    +

    Deploy behind nginx

    +

    The proxy is a single PHP script — any SAPI works. nginx + php-fpm is a common production setup. PATH_INFO is what the proxy reads to learn the target URL.

    server {
    +  listen 443 ssl http2;
    +  server_name cors.example.com;
    +
    +  root /var/www/cors-proxy;
    +  index cors-proxy.php;
    +
    +  location ~ ^/cors-proxy\.php(/.*)?$ {
    +    fastcgi_pass unix:/run/php/php8.1-fpm.sock;
    +    fastcgi_split_path_info ^(.+\.php)(/.*)$;
    +    fastcgi_param SCRIPT_FILENAME $document_root/cors-proxy.php;
    +    fastcgi_param PATH_INFO $fastcgi_path_info;
    +    include fastcgi_params;
    +  }
    +}
    +

    Full API reference: CORSProxy README.

    diff --git a/docs/dataliberation/index.html b/docs/dataliberation/index.html index 5f220cffa..4e90f0b78 100644 --- a/docs/dataliberation/index.html +++ b/docs/dataliberation/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,9 +51,9 @@

    DataLiberation

    Streaming WordPress import/export. WXR, SQL, block markup — without loading whole datasets into memory.

    composer require wp-php-toolkit/data-liberation

    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.

    -

    Write a WXR export

    -

    Feed entities to WXRWriter in logical order: post first, then meta/terms/comments belonging to it.

    - +

    Write a WXR file in five lines

    +

    Stream a single post into a WXR document via WXRWriter. The writer holds no buffer beyond what is needed to close currently-open tags, so memory stays flat regardless of input size.

    + + +

    Build a WXR programmatically from any source

    +

    The writer doesn't care where entities come from. Loop over rows from a CMS, a CSV, or a Notion API dump and emit posts plus their meta and comments.

    + + + +

    Read entities from a WXR file with constant memory

    +

    WXREntityReader emits one entity at a time. A 10 GB WXR uses the same memory as a 10 KB one.

    + + + +

    Streaming transform: rewrite URLs while copying WXR

    +

    Wire reader to writer to rewrite a WXR file on the fly. This pattern is how you migrate a staging export to production: swap staging.example.com for example.com without ever loading the file into memory.

    + + + +

    Render Markdown into a WXR import in one pipeline

    +

    Compose MarkdownConsumer with WXRWriter to publish a folder of Markdown directly as a WordPress import file.

    + +

    Full API reference: DataLiberation README.

    diff --git a/docs/encoding/index.html b/docs/encoding/index.html index b6aa09fee..29b9c4241 100644 --- a/docs/encoding/index.html +++ b/docs/encoding/index.html @@ -4,7 +4,7 @@ Encoding — PHP Toolkit - + @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,7 +51,8 @@

    Encoding

    Pure-PHP UTF-8 validation and scrubbing. Detects malformed bytes, replaces them per the Unicode maximal-subpart algorithm, and works without mbstring.

    composer require wp-php-toolkit/encoding

    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.

    -

    Validate

    +

    Validating UTF-8 before storing it

    +

    wp_is_valid_utf8() rejects overlong sequences, surrogate halves, and stray ISO-8859-1 bytes. Use it as a guard in front of any code path that assumes UTF-8 (database, JSON, XML).

    -

    Scrub invalid bytes

    -

    Replace each maximal subpart with U+FFFD.

    +

    Scrubbing invalid bytes with U+FFFD

    +

    Replace each ill-formed sequence with the Unicode replacement character. Useful right before serializing to XML, JSON, or sending to an LLM that will choke on broken bytes.

    -

    Detect noncharacters

    -

    Code points like U+FFFE that should never appear in interchange.

    +

    Detecting noncharacters MySQL/utf8mb4 will reject

    +

    Code points like U+FFFE, U+FFFF, and the U+FDD0–U+FDEF block are valid Unicode but forbidden in XML and rejected by some databases. Check before inserting user-submitted content into a strict utf8mb4 column.

    + +

    Three-way pipeline: validate, scrub, then check noncharacters

    +

    Real-world inputs are messy: an old WXR export, a CSV with mixed encodings, a paste from Word. Combination of validate + scrub + noncharacter-check covers the three classes of breakage that bite later.

    + + + +

    Salvaging a legacy ISO-8859-1 column inside a UTF-8 corpus

    +

    Old WordPress databases sometimes mix encodings: most rows are UTF-8 but a few were stored as latin-1. Detect the bad rows with wp_is_valid_utf8() and only re-encode those.

    + +

    Full API reference: Encoding README.

    diff --git a/docs/filesystem/index.html b/docs/filesystem/index.html index ed5605a8c..fda833af9 100644 --- a/docs/filesystem/index.html +++ b/docs/filesystem/index.html @@ -4,7 +4,7 @@ Filesystem — PHP Toolkit - + @@ -33,29 +33,29 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • Filesystem

    -

    A unified filesystem abstraction across local disk, in-memory trees, SQLite, and ZIP archives. Forward-slash paths everywhere, even on Windows.

    +

    One Filesystem interface across local disk, in-memory trees, SQLite databases, and ZIP archives. Forward-slash paths everywhere — even on Windows — so the same code runs in tests, in production, and inside read-only ZIPs.

    composer require wp-php-toolkit/filesystem

    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.

    -

    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.

    +

    Why this exists

    +

    Code that touches the filesystem is hard to test, hard to port to Windows, and impossible to point at non-disk storage without rewriting it. Swap LocalFilesystem for InMemoryFilesystem in tests and your suite stops touching /tmp; swap it for SQLiteFilesystem and your "files" become rows in a portable database; swap it for ZipFilesystem and you can read inside an archive with the same calls.

    Every backend uses forward slashes regardless of host OS. No DIRECTORY_SEPARATOR juggling, no Windows-only test failures, no surprises when a path moves between backends.

    In-memory tree

    -

    The fastest backend. Stores everything in PHP arrays; ideal for tests and ephemeral processing.

    - +

    The fastest backend. No disk I/O, no cleanup, no test-isolation problems.

    + + +

    Test code without touching disk

    +

    Pass production code a Filesystem instead of using file_get_contents directly, and your tests run against an in-memory tree with no setup or teardown.

    + + -

    Local disk

    -

    LocalFilesystem::create($root) chroots all paths to $root. The forward-slash convention is enforced even on Windows.

    - +

    Local disk with a chrooted root

    +

    LocalFilesystem::create($root) is implicitly chrooted: every path resolves relative to $root and a ../ can't escape. Useful when the path comes from user input.

    + -

    SQLite-backed

    -

    Everything lives in a single SQLite file. Convenient for portable scratch storage that survives a process boundary.

    +

    SQLite as a portable file store

    +

    The whole tree lives in one SQLite file you can ship anywhere PHP runs. Useful for plugins that want self-contained scratch storage that survives process boundaries without leaving loose files behind.

    -

    Walk a tree

    -

    The interface includes a recursive walker so you can iterate every file regardless of backend.

    - +

    Copy a tree across backends

    +

    The killer composability move: copy_between_filesystems() streams files chunk-by-chunk from any source to any target. Pull a ZIP into SQLite, snapshot SQLite to disk, mirror disk into RAM — all the same call.

    + + +

    Atomic write via tempfile rename

    +

    Write to a sibling tempfile, then rename — that's how you avoid leaving a half-written file on crash. rename() is atomic within a single filesystem.

    + + + +

    Path helpers that behave the same on Windows

    +

    Unix path semantics on every host OS. Useful when the path is something abstract — a key in a SQLite filesystem, an entry name in a ZIP — that doesn't live on a real drive.

    + +

    Full API reference: Filesystem README.

    diff --git a/docs/git/index.html b/docs/git/index.html index 6c59fc414..d116ecfc7 100644 --- a/docs/git/index.html +++ b/docs/git/index.html @@ -4,7 +4,7 @@ Git — PHP Toolkit - + @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,9 +51,9 @@

    Git

    A pure-PHP Git client and server. Commits, branches, diffs, HTTP push/pull — all without shelling out to git.

    composer require wp-php-toolkit/git

    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.

    -

    Commit files in memory

    -

    The repository builds the blob, tree, and commit objects for you. Backed by any Filesystem, including InMemoryFilesystem for ephemeral repos.

    - +

    Commit files into an in-memory repo

    +

    The simplest possible repository: an InMemoryFilesystem as object storage and one commit() call. Reach for this in tests, in WP-CLI snapshots, or any place you want versioning without touching disk.

    + -

    Read objects by hash

    -

    Every Git object is identified by its SHA-1. Store a blob, get the hash back, read it later.

    - +

    Walk the commit history

    +

    Follow the parent chain from HEAD backwards. Building block for a WP-CLI "post revisions" log or a "what changed since release X" report.

    + + +

    Treat a repository like a filesystem

    +

    GitFilesystem wraps a repository in the standard Filesystem interface. Every put_contents() auto-commits.

    + + + +

    Branch, edit, and switch back

    +

    Create a feature branch off the current commit, change files, flip HEAD back. Useful for experimental edits in collaborative tools.

    + + + +

    Three-way merge two branches

    +

    The classic Git workflow: branch off, edit on each side, merge. $repo->merge() finds the common ancestor, three-way-merges every file, and creates a merge commit.

    + + + +

    Snapshot WordPress options into a repo

    +

    Serialize a chunk of WP state (options, post meta, a theme config) on every save and commit it. You get free history, diffs between snapshots, and a "rollback to last week" button.

    + +

    Full API reference: Git README.

    diff --git a/docs/html/index.html b/docs/html/index.html index 6df000030..265f2dd41 100644 --- a/docs/html/index.html +++ b/docs/html/index.html @@ -4,7 +4,7 @@ HTML — PHP Toolkit - + @@ -33,28 +33,28 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • HTML

    -

    A full HTML5 parser and tag processor in pure PHP, mirroring WordPress core's HTML API. No libxml2, no DOM extension, no external dependencies.

    +

    A pure-PHP HTML5 parser and tag rewriter mirroring WordPress core's HTML API. Treat HTML the way browsers do — without libxml2, DOMDocument, or regex hacks — and rewrite attributes in a single linear pass.

    composer require wp-php-toolkit/html

    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.

    -

    Why this exists

    -

    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.

    -

    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.

    +

    Two layers, one mental model

    +

    The component gives you two processors. WP_HTML_Tag_Processor is a forward-only cursor over tags and tokens — perfect for attribute rewriting at scale. WP_HTML_Processor layers full HTML5 tree construction on top so you can query by ancestry (breadcrumbs), serialize back to well-formed HTML, and trust that <p>one<p>two parses as two paragraphs the way a browser sees it.

    Footgun: mutations are buffered. Nothing changes in the source string until you call get_updated_html(). If you read get_attribute() after a set_attribute() on the same tag, you see the new value — but downstream tooling reading the original string sees stale HTML until you serialize.

    +

    Add loading="lazy" to every image

    +

    The "hello world" of tag rewriting. One linear pass, no DOM, no reserialization cost beyond the bytes you actually changed.

    -

    Query by tag and class

    -

    Pass an array to next_tag() to find tags matching a tag name, a CSS class, or both. The processor never builds a DOM; it just advances a cursor.

    - + +

    Useful when rendering content for an RSS feed, an email, or a site behind a CDN where relative paths break. The processor only rewrites the bytes that changed, so untouched markup stays byte-identical.

    + -

    Walk the structure

    -

    WP_HTML_Processor understands HTML5 tree construction. You can ask it for the current depth, breadcrumbs, and whether a tag is implicitly closed.

    - +

    Strip every script and inline event handler

    +

    A common sanitization step: neutralize untrusted HTML before display. Blank a script's body with set_modifiable_text() and strip every on* attribute via get_attribute_names_with_prefix().

    + -

    Decode HTML entities

    -

    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.

    - +

    Stamp a CSP nonce on inline scripts and styles

    +

    Content Security Policy in nonce- mode requires every inline <script> and <style> to carry a matching nonce attribute. Tag-by-tag is exactly the right granularity.

    + + +

    Build a srcset from a single src

    +

    Generate responsive image markup at render time without touching the editor data model. Read the existing src, derive a srcset with width descriptors, add a sizes hint.

    + + -

    Insert and remove subtrees

    -

    The full processor lets you replace, insert, or remove entire subtrees by addressing a bookmark. Useful for scrubbing posts before display.

    - +

    Decode HTML entities the way the spec demands

    +

    The HTML5 entity table has roughly 2,200 named references and a long list of edge cases. WP_HTML_Decoder implements the algorithm — don't roll your own.

    + + +

    Find images by ancestry with breadcrumbs

    +

    The full WP_HTML_Processor understands HTML5 tree construction, so you can ask "find every <img> directly inside a <figure>" without writing your own DOM walker.

    + + + +

    Outline a document by walking tokens with depth

    +

    The full processor exposes get_current_depth() and get_breadcrumbs(). Combine with next_token() to print a structural outline.

    + + + +

    Bookmarks: annotate a parent based on its children

    +

    Bookmarks are the one escape from forward-only scanning. Save a position, scan ahead, decide what to do, then seek() back and rewrite the earlier tag.

    + + diff --git a/docs/httpclient/index.html b/docs/httpclient/index.html index 3adb82fec..f70656afd 100644 --- a/docs/httpclient/index.html +++ b/docs/httpclient/index.html @@ -4,7 +4,7 @@ HttpClient — PHP Toolkit - + @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -52,7 +52,7 @@

    HttpClient

    composer require wp-php-toolkit/http-client

    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.

    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.

    +

    Network access in the demo runtime. Snippets execute inside a sandboxed Playground; outbound HTTP requires the CORS proxy. The examples below show the API; real network calls may not complete in this environment.

    GET a URL

    Build a Request, hand it to Client::fetch(), await the response, read the body.

    @@ -70,6 +70,125 @@

    GET a URL

    echo "status: " . $response->status_code . "\n"; echo "first 80 bytes: " . substr( $stream->consume_all(), 0, 80 ) . "\n"; +
    +

    Inspect headers without reading the body

    +

    Call await_response() to get the Response as soon as headers arrive. Useful for HEAD-style metadata checks, content-type sniffing, or deciding whether to keep reading.

    + + + +

    POST JSON with a request body

    +

    Stream a JSON body up to the server using MemoryPipe. Same pattern works for any payload by switching the content-type header.

    + + + +

    Parallel fan-out: fetch many URLs at once

    +

    Enqueue a batch of requests and react to events as they fire. The client multiplexes them — total wall time is roughly the slowest request, not the sum.

    + + + +

    Stream a download to disk without OOM

    +

    Process the body chunk-by-chunk via the event loop. Memory stays flat regardless of file size.

    + +

    Full API reference: HttpClient README.

    diff --git a/docs/httpserver/index.html b/docs/httpserver/index.html index ffe6aff92..cb32ec9c5 100644 --- a/docs/httpserver/index.html +++ b/docs/httpserver/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,13 +51,11 @@

    HttpServer

    A minimal blocking TCP HTTP server in pure PHP. For CLI tools and tests, not for production traffic.

    composer require wp-php-toolkit/http-server

    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.

    -

    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.

    - +

    Hello world on port 8080

    +

    Run on your machine: the Playground sandbox does not allow processes to bind listening TCP ports. Save this snippet locally and run php hello-server.php.

    + + +

    A tiny JSON router

    +

    Run on your machine: needs a listening port. Once running, try curl localhost:8080/api/status.

    Build a CLI tool with a web UI by switching on the parsed path and method.

    + + + +

    Buffered response with auto Content-Length

    +

    Use BufferingResponseWriter when you want the framework to compute Content-Length for you, or when the runtime is CGI-shaped and expects the full body up front. This one runs anywhere — no socket required.

    + +

    Full API reference: HttpServer README.

    diff --git a/docs/index.html b/docs/index.html index ac9ae17ce..45dc4fc3c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -20,23 +20,23 @@

    PHP Toolkit

    Components

    diff --git a/docs/markdown/index.html b/docs/markdown/index.html index 5b029e006..4e0701121 100644 --- a/docs/markdown/index.html +++ b/docs/markdown/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -52,24 +52,21 @@

    Markdown

    composer require wp-php-toolkit/markdown

    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.

    Markdown to blocks

    -

    Pass a Markdown string to MarkdownConsumer and call consume(). The result exposes block markup and any YAML frontmatter as metadata.

    - +

    Feed Markdown into MarkdownConsumer, get block markup back. The result is a BlocksWithMetadata object that holds both the rendered blocks and any frontmatter parsed from the document.

    + -

    Blocks back to Markdown

    -

    MarkdownProducer walks block markup and emits matching Markdown. Round-tripping a document should produce equivalent output (modulo whitespace normalization).

    - +

    Round-trip: blocks back to Markdown

    +

    Pair MarkdownProducer with MarkdownConsumer to convert in either direction. Round-tripping is lossy for block attributes that have no Markdown representation (custom classes, alignment), so do not expect byte-perfect equality.

    + -

    Frontmatter

    -

    YAML frontmatter is parsed and exposed as metadata so the block markup stays clean.

    +

    Reading YAML frontmatter as post meta

    +

    Frontmatter keys come back as arrays so a single key can hold multiple values. Use get_meta_value() when you only want the first scalar.

    + +

    Migrating an Obsidian or Hugo folder of Markdown

    +

    Walk a directory of .md files (Obsidian vault, Hugo content/, Jekyll _posts) and emit one block-markup record per file.

    + + + +

    Counting blocks produced by a Markdown document

    +

    After conversion, the block markup is plain WordPress block markup, so parse_blocks() works on it directly. The standard way to introspect what the converter emitted before saving to the database.

    + +

    Full API reference: Markdown README.

    diff --git a/docs/merge/index.html b/docs/merge/index.html index ced2b77ef..a0fc7a51d 100644 --- a/docs/merge/index.html +++ b/docs/merge/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,8 +51,48 @@

    Merge

    Three-way merge and diff. Pluggable differ + merger + optional validator.

    composer require wp-php-toolkit/merge

    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.

    -

    Merge two branches

    -

    Give it a base, branch A, and branch B. Get a merge result with conflicts (if any) and the merged content.

    +

    Diff two strings line by line

    +

    Feed two strings to LineDiffer and inspect the operations. Every get_changes() entry is a [op, text] pair.

    + + + +

    Render a unified patch

    +

    format_as_git_patch() produces output that mirrors git diff, including hunk headers — handy for emails, CI annotations, or a "what changed?" panel.

    + + + +

    Three-way merge with no conflicts

    +

    The classic case: each branch changes a different region. Pass the common ancestor plus both edits to MergeStrategy::merge() and read the merged result.

    -

    Inspect a diff

    -

    The Diff object is a flat list of equal/insert/delete operations.

    - +

    Inspect and surface conflicts

    +

    When both sides edit the same region, the merger produces a MergeConflict. The merged content carries Git-style markers, but the structured get_conflicts() output is what you want for a UI that lets the user pick a side.

    + + +

    Sync a Markdown folder against an edited DB copy

    +

    A real-world scenario: posts live both in a Git-tracked Markdown folder and in WordPress, and someone edits each. Three-way-merge each post against its common ancestor.

    + + diff --git a/docs/polyfill/index.html b/docs/polyfill/index.html index d30406111..86847e88d 100644 --- a/docs/polyfill/index.html +++ b/docs/polyfill/index.html @@ -33,15 +33,15 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • @@ -51,31 +51,105 @@

    Polyfill

    PHP 8 string functions on PHP 7.2+, WordPress hook stubs, and translation/escaping passthroughs so toolkit code runs without WordPress.

    composer require wp-php-toolkit/polyfill

    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.

    -

    PHP 8 strings on 7.2

    -

    Polyfills are loaded automatically through Composer's autoload.files. They define functions only when missing, so they're safe to use alongside PHP 8.

    +

    Why this exists

    +

    A lot of WordPress-adjacent code wants to call esc_html(), __(), or apply_filters() without booting WordPress. The polyfill component provides minimal but real implementations so that code runs unchanged outside WordPress, and stays out of the way when WordPress is loaded (every function uses function_exists() guards).

    +

    PHP 8 string functions on PHP 7.2

    +

    The polyfills define str_contains, str_starts_with, str_ends_with, and array_key_first only when missing.

    + +

    Escaping and translation stubs

    +

    Pass-through implementations let you write code that looks WordPressy and runs anywhere.

    + + + +

    A simple filter chain

    +

    The hook system is a real implementation of the WordPress filter API: registered callbacks get applied in priority order, and each one transforms the running value.

    + + + +

    Priority ordering and multi-arg passing

    +

    Lower priority numbers run first. The fourth argument to add_filter controls how many context values get passed to the callback.

    + + -

    WordPress hooks without WordPress

    -

    A minimal but real implementation of add_filter, apply_filters, and the action equivalents — priorities and all.

    - +

    Hook-based extension points in standalone libraries

    +

    Use do_action and apply_filters as cheap extension points in your own code, without depending on WordPress.

    +

    Full API reference: Polyfill README.

    diff --git a/docs/xml/index.html b/docs/xml/index.html index 217283ca1..b83b43015 100644 --- a/docs/xml/index.html +++ b/docs/xml/index.html @@ -4,7 +4,7 @@ XML — PHP Toolkit - + @@ -33,64 +33,142 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • XML

    -

    Streaming XML processor without libxml2. Modify attributes, walk namespaces, scan large documents without loading them into memory.

    +

    A streaming, namespace-aware XML processor in pure PHP. Read and modify huge feeds, WXR exports, ePub manifests, and Office Open XML parts without ever loading the document into memory and without depending on libxml2.

    composer require wp-php-toolkit/xml

    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.

    -

    Read and rewrite an attribute

    -

    The XMLProcessor mirrors the HTML tag processor — find a tag, read or set attributes, get the modified document back.

    - +

    Why a streaming XML processor

    +

    SimpleXMLElement and DOMDocument both need libxml2 and both build a complete in-memory tree. XMLProcessor walks the document forward as a cursor, keeps modifications in a side buffer, and emits the full updated XML with get_updated_xml() only when you ask for it.

    Footgun #1: namespaces are addressed by URI, never by prefix. get_attribute( 'wp', 'status' ) always returns null; you want get_attribute( 'http://wordpress.org/export/1.2/', 'status' ).

    Footgun #2: in streaming mode next_tag() can return false because input ran out, not because the document ended. Check is_paused_at_incomplete_input() before assuming you're done.

    +

    Bump every price in a catalog

    +

    Find each <book>, read its price, write a new one, emit the updated document.

    + -

    Namespaces are first-class

    -

    Methods take a namespace URI as the first argument, never a prefix. The processor resolves prefixes itself.

    - +

    Read namespaced attributes from a WXR export

    +

    WordPress's WXR uses wp:, dc:, and content: prefixes. Always pass the URI, not the prefix; the processor handles whichever prefix the document actually uses.

    + + +

    Rewrite URLs across an entire WXR export

    +

    WXR holds tens of thousands of URLs in <link>, <guid>, and post content. Streaming the file lets you rewrite multi-hundred-megabyte exports without going OOM.

    + + + +

    Parse OPML to extract feed URLs

    +

    OPML is the format Feedly and many readers use to import/export feed lists. Flat, attribute-heavy XML — exactly what a tag processor handles best.

    + +

    Full API reference: XML README.

    diff --git a/docs/zip/index.html b/docs/zip/index.html index 8d6745551..2e2cf6259 100644 --- a/docs/zip/index.html +++ b/docs/zip/index.html @@ -4,7 +4,7 @@ Zip — PHP Toolkit - + @@ -33,63 +33,59 @@
  • Markdown
  • XML
  • Encoding
  • -
  • Polyfill
  • -
  • CLI
  • +
  • DataLiberation
  • Git
  • Merge
  • HttpClient
  • HttpServer
  • CORSProxy
  • +
  • CLI
  • +
  • Polyfill
  • Blueprints
  • -
  • DataLiberation
  • ToolkitCodingStandards
  • 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.

    +

    Read and write ZIP archives in pure PHP — no libzip, no ZipArchive. Streams entries one at a time, so you can build EPUBs, .docx files, and multi-gigabyte plugin bundles without buffering the archive in memory.

    composer require wp-php-toolkit/zip

    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.

    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.

    -

    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.

    - +

    The PHP ecosystem has two ZIP options: the ZipArchive extension (often missing on shared hosts and stripped from WebAssembly builds) and shelling out to zip. Neither helps you stream a 4 GB plugin bundle to the browser, peek at an EPUB manifest without unpacking it, or build a .docx on a host without libzip.

    The Zip component reads and writes Stored and Deflate archives in pure PHP. The decoder is pull-based, so listing the central directory of a 2 GB ZIP costs roughly the size of the directory itself. The encoder accepts any ByteWriteStream as a sink and writes one entry at a time.

    +

    Read a file out of a ZIP

    +

    ZipFilesystem implements the standard Filesystem interface, so once you wrap the byte reader you can call get_contents(), ls(), is_dir() just like local disk.

    + -

    Read entries through a filesystem

    -

    ZipFilesystem implements the toolkit's Filesystem interface, so you can ls(), is_file(), and get_contents() as if the archive were a directory tree.

    - +

    Build an EPUB from scratch

    +

    An EPUB is a ZIP with one strict rule: the mimetype entry must come first and must be Stored. Everything else can be Deflate.

    Gotcha: e-readers reject EPUBs whose mimetype entry has compression. Use COMPRESSION_NONE for that single entry.

    + -

    Stream a large file out of an archive

    -

    For multi-megabyte entries inside an archive, use open_read_stream() instead of loading the whole file. The decoder inflates as you pull.

    - +

    Stream a large entry without buffering it

    +

    Calling get_contents() on a 500 MB CSV inside a ZIP would eat 500 MB of RAM. Use open_read_stream() instead and inflate-as-you-go.

    Gotcha: only one entry stream open at a time. Drain or finish the previous stream before opening the next.

    + + +

    Repack: modify one file, copy the rest

    +

    Updating one file in a ZIP without rewriting the others is impossible at the format level — the central directory points at byte offsets. The pragmatic answer is repack: stream the source archive into a new one, swapping the file you care about.

    + + + +

    Defend against zip-slip

    +

    A malicious archive can name an entry ../../etc/passwd and trick a naive extractor into clobbering files outside the destination. ZipDecoder::sanitize_path() strips leading ../ segments and collapses internal /../ sequences before exposing the path.

    + + + +

    Pipe ZIP entries into an InMemoryFilesystem

    +

    Real-world recipe: take an uploaded plugin ZIP, expand it into an InMemoryFilesystem so you can validate, edit, or scan it before it ever touches disk. Three components compose into something you couldn't build with ZipArchive alone.

    + +

    Full API reference: Zip README.

    From 3c626d9752193d356cb2d88b6581b4db7c480d68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 28 Apr 2026 21:20:00 +0200 Subject: [PATCH 6/7] Exclude /docs from phpcs The runnable-docs site lives under /docs, including a JS bundle that phpcs tries to lint as PHP. Mirror the existing /examples exclusion. --- phpcs.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/phpcs.xml b/phpcs.xml index c20486124..02ed3994a 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -11,6 +11,7 @@ /plugins/url-updater/ /bin/build-phar /examples/ + /docs/ rector.php components/CORSProxy/cors-proxy-functions.php components/Markdown/bin/build/* From 8ff2ed0a95a19b02de4975304e689c49819e9731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Sat, 2 May 2026 02:50:02 +0200 Subject: [PATCH 7/7] Pre-render snippet output via expected-output (#245) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on top of #244. Wires the docs site into [wordpress-playground#3555](https://github.com/WordPress/wordpress-playground/pull/3555), which added an `expected-output` mechanism to the `` web component. When a snippet ships its expected output, Run renders that output instantly without booting Playground at all — no PHP/WordPress download, no iframe, no wait. ## How it works `bin/run-snippets.py` runs every PHP snippet in the catalog against the local toolkit and captures stdout: ```sh composer install bin/run-snippets.py --update # regenerates bin/_expected_outputs.json bin/run-snippets.py --check # CI mode: verify outputs match committed JSON ``` The runner rewrites each snippet's `/wordpress/wp-content/php-toolkit/vendor/autoload.php` to the repo's local `vendor/autoload.php` and injects a tiny `parse_blocks()` polyfill so WordPress-specific helpers don't break local runs. Outputs go through a small normalizer that strips temp-file paths, OIDs, and random nonces so the JSON is stable across runs. `bin/build-docs.py` reads the JSON and emits a `", + "html::decode-entities.php": "attribute: path?a=1&b=2©\ntext: AT&T \u2014 100% \ud83d\ude00\nbool(false)\n", + "html::lazy-load-images.php": "
    \n\t\"Hero\"\n\t

    Intro copy.

    \n\t\"Inline\"\n
    ", + "html::outline.php": " H1 Title\n H2 Chapter 1\n H2 Chapter 2\n", + "html::sanitize-html.php": "

    Hi friend!

    ", + "html::srcset-rewrite.php": "
    \"Sunset\"
    ", + "httpclient::parse-response.php": "status: 201 Created\nok: yes\ntype: application/json\nsize: 27 bytes\n", + "httpclient::request-object.php": "POST https://api.example.test/posts\ncontent-type: application/json\ncontent-length: 39\nauthorization: Basic dXNl...\n", + "httpserver::buffered-writer.php": "headers before send:\nContent-Type: text/html\n\nbody:\nHi

    Hello

    Buffered body, sent at the end.

    ", + "markdown::count-blocks.php": "core/heading: 1\ncore/paragraph: 2\ncore/table: 1\ncore/code: 1\ncore/quote: 1\n", + "markdown::frontmatter.php": "Title: The Name of the Wind\nStatus: publish\nTags: fantasy, kingkiller\n", + "markdown::migrate-folder.php": "=== roadmap (/tmp//roadmap.md) ===\n\n

    Roadmap

    \n\n\n\n

    Hello world.

    \n\n\n...\n\n", + "markdown::quickstart.php": "\n

    Hello

    \n\n\n\n

    Welcome to WordPress.

    \n\n\n", + "markdown::roundtrip.php": "## Round trip\n\n- one\n- two\n- three\n\n", + "merge::conflicts.php": "ours: line 2 from Alice\ntheirs: line 2 from Bob\n\n--- merged content with markers ---\nline 1\n\n<<<<<<< HEAD\nline 2 from Alice\n\n=======\nline 2 from Bob\n\n>>>>>>> incoming \n\n", + "merge::git-patch.php": "diff --git a/post.yml b/post.yml\n--- a/post.yml\n+++ b/post.yml\n@@ -1,4 +1,5 @@- title: Hello\n+ title: Hello, world\n author: Alice\n- status: draft\n+ status: published\n+ tags: greeting\n \n", + "merge::line-diff.php": "= alpha\n- beta\n+ BETA\n= gamma\n+ delta\n= \n", + "merge::sync-folder-vs-db.php": "=== hello.md ===\n(conflict \u2014 needs review)\n# Hello\n\n<<<<<<< HEAD\nDraft body, expanded on disk.\n\n=======\nNew section from the editor.\n\n>>>>>>> incoming \n\n\n=== about.md ===\n(conflict \u2014 needs review)\n# About\n\n<<<<<<< HEAD\nWho *they* are.\n\n=======\nWho we really are.\n\n>>>>>>> incoming \n\n\n", + "merge::three-way.php": "clean merge:\nintro updated\nbody\noutro\nappendix\n\n", + "polyfill::filter-chain.php": "my-post-title\n", + "polyfill::library-hooks.php": "user@example.com\nother@example.com\n", + "polyfill::php8-strings.php": "bool(true)\nbool(true)\nbool(true)\nfirst key: alpha\n", + "polyfill::priority-args.php": "19.99 EUR (EUR markup)\n", + "polyfill::wp-stubs.php": "Hello, world\n<script>alert("xss")</script>\na "quoted" value\nhttps://example.com/?a=1&b=2\n", + "xml::bump-prices.php": "PHP InternalsWordPress at Scale", + "xml::opml.php": "Hacker News\thttps://news.ycombinator.com/rss\nLWN\thttps://lwn.net/headlines/rss\nWordPress\thttps://wordpress.org/news/feed/\n", + "xml::rewrite-wxr-urls.php": "rewrote 3 text nodes\n\nhttps://new.example.comhttps://new.example.com/2024/post-1https://new.example.com/?p=1", + "xml::wxr-namespaces.php": "title: Hello World\ndc/creator: admin\nwp/post_id: 42\nwp/status: publish\n", + "zip::epub.php": "mimetype: application/epub+zip\nsize on disk: 839 bytes\n", + "zip::repack.php": "new config.json: {\"debug\":true,\"version\":\"1.0.1\"}\nuntouched: etc/passwd\n./safe/path.txt => ./safe/path.txt\na/../../b/secret => a/../b/secret\na//b///c.txt => a/b/c.txt\n../../../../root/.ssh/authorized_keys => root/.ssh/authorized_keys\n", + "zip::zip-to-memfs.php": "files now in memory:\n /app/README.md\n /app/VERSION\n /app/assets/style.css\n /app/index.php\n /app/lib/util.php\n" +} diff --git a/bin/build-docs-bundle.sh b/bin/build-docs-bundle.sh index f7bf91b6c..ce6eb195d 100755 --- a/bin/build-docs-bundle.sh +++ b/bin/build-docs-bundle.sh @@ -14,7 +14,10 @@ rm -f docs/assets/php-toolkit.zip zip -qr docs/assets/php-toolkit.zip components vendor bootstrap.php composer.json \ -x "*/Tests/*" "*/tests/*" "*/.git/*" "*/.github/*" "*/node_modules/*" -echo "==> generating docs/*/index.html" +echo "==> regenerating legacy docs/_legacy/*/index.html" python3 bin/build-docs.py +echo "==> regenerating docs/reference/*.html" +python3 bin/build-reference.py + echo "Done. docs/assets/php-toolkit.zip = $(du -h docs/assets/php-toolkit.zip | cut -f1)" diff --git a/bin/build-docs.py b/bin/build-docs.py index aab009a38..ede776442 100755 --- a/bin/build-docs.py +++ b/bin/build-docs.py @@ -5,15 +5,28 @@ content and orchestration stay separate. """ +import json import os import re import sys from html import escape as h sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from _docs_components import COMPONENTS +from _docs_components import ( + COMPONENTS, + COMPONENT_GUIDES, + COMPONENT_RELATIONS, + STARTER_PATHS, +) -DOCS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'docs') +DOCS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'docs', '_legacy') +EXPECTED_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '_expected_outputs.json') +ASSET_VERSION = '20260429-concept-guide' + +EXPECTED = {} +if os.path.exists(EXPECTED_PATH): + with open(EXPECTED_PATH) as f: + EXPECTED = {tuple(k.split('::')): v for k, v in json.load(f).items()} PAGE_HEAD = ''' @@ -22,10 +35,10 @@ {title} — PHP Toolkit - + - +
    @@ -45,18 +58,37 @@ ''' -def snippet_block(name, code): +def snippet_block(slug, name, code, runnable=True): # \n' + ) + runnable_attr = '' if runnable else ' runnable="false"' return ( - f'\n' + f'\n' f'\n' + f'{expected_block}' f'\n' ) +def render_example(slug, snippet): + name, code = snippet[0], snippet[1] + runnable = len(snippet) < 3 or snippet[2] + if not runnable: + return snippet_block(slug, name, code, False) + return snippet_block(slug, name, code, True) + + def slugify(text): return re.sub(r'[^\w\s-]', '', text.lower()).strip().replace(' ', '-') @@ -80,7 +112,11 @@ def render_component(slug, title, lede, install, sections): '\t\n' ) - out = [PAGE_HEAD.format(title=h(title), description=h(re.sub(r'<[^>]+>', '', lede)))] + out = [PAGE_HEAD.format( + title=h(title), + description=h(re.sub(r'<[^>]+>', '', lede)), + asset_version=ASSET_VERSION, + )] out.append('
    \n') out.append(sidebar) out.append('\t
    \n') @@ -88,63 +124,94 @@ def render_component(slug, title, lede, install, sections): out.append(f'\t\t

    {lede}

    \n') if install: out.append(f'\t\tcomposer require {h(install)}\n') - out.append( - '\t\t

    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: + + purpose = None + usage_sections = sections + if sections and sections[0][0].lower() == 'why this exists': + purpose = sections[0] + usage_sections = sections[1:] + + if purpose: + _, body_html, snippet = purpose + if body_html: + out.append(f'\t\t{body_html}\n') + if snippet: + out.append(render_example(slug, snippet)) + + guide = COMPONENT_GUIDES.get(slug, {}) + if guide: + mental_model = guide.get('mental_model') + journey = guide.get('journey', ()) + if mental_model: + out.append(f'\t\t{mental_model}\n') + if journey: + out.append('\t\t

    You will learn to:

    \n') + out.append('\t\t
      \n') + for label, _text in journey: + out.append(f'\t\t\t
    • {h(label)}
    • \n') + out.append('\t\t
    \n') + + if install: + out.append( + '\t\t

    Most snippets below run in the browser through WordPress Playground. ' + 'Click Run on any example to execute it; edit the code and run again to see what changes. ' + 'Static snippets show config or shell commands that need a real local environment.

    \n' + ) + + for heading, body_html, snippet in usage_sections: out.append(f'\t\t

    {h(heading)}

    \n') if body_html: out.append(f'\t\t{body_html}\n') if snippet: - name, code = snippet - out.append(snippet_block(name, code)) - readme_dir = title.replace(' ', '') - if slug == 'coding-standards': - readme_dir = 'ToolkitCodingStandards' - elif slug == 'httpclient': - readme_dir = 'HttpClient' - elif slug == 'httpserver': - readme_dir = 'HttpServer' - elif slug == 'corsproxy': - readme_dir = 'CORSProxy' - elif slug == 'dataliberation': - readme_dir = 'DataLiberation' - elif slug == 'blockparser': - readme_dir = 'BlockParser' - elif slug == 'bytestream': - readme_dir = 'ByteStream' - out.append( - '\t\t

    ' - f'Full API reference: {h(title)} README.

    \n' - ) + out.append(render_example(slug, snippet)) + + related = COMPONENT_RELATIONS.get(slug, ()) + if related: + out.append('\t\t

    See also

    \n') + out.append('\t\t\n') out.append('\t
    \n
    \n') out.append(PAGE_FOOT) return ''.join(out) def render_index(): + title_by_slug = {slug: title for slug, title, _, _, _ in COMPONENTS} cards = [] for slug, title, lede, _, _ in COMPONENTS: clean = re.sub(r'<[^>]+>', '', lede) first = clean.split('.')[0] if len(first) > 110: - first = first[:107] + '…' + first = first[:107].rsplit(' ', 1)[0] + '…' + suffix = '' if first.endswith(('…', '.')) else '.' cards.append( f'\t\t
  • {h(title)}' - f'{h(first)}.
  • ' + f'{h(first)}{suffix}' ) cards_html = '\n'.join(cards) + path_cards = [] + for title, description, slugs in STARTER_PATHS: + links = ' '.join( + f'{h(title_by_slug[slug])}' for slug in slugs + ) + path_cards.append( + f'\t\t
  • {h(title)}{h(description)}' + f'
  • ' + ) + paths_html = '\n'.join(path_cards) return f''' PHP Toolkit — runnable docs - - + +
    @@ -155,7 +222,12 @@ def render_index():
    \t

    PHP Toolkit

    -\t

    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

    Eighteen standalone pure-PHP libraries for WordPress and general PHP, with no extension or Composer dependencies. Each guide starts with the story for that component, outlines the route through the page, names the main APIs, and then uses examples only where code clarifies the idea.

    + +\t

    Choose a Path

    +\t
      +{paths_html} +\t
    \t

    Components

    \t
      @@ -163,7 +235,8 @@ def render_index(): \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

    Most PHP examples embed <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

    Examples that need a local listening port, a web server, or deployment-specific config are presented as static code blocks so the page does not imply they can run in the browser sandbox.

    \t

    The toolkit bundle (docs/assets/php-toolkit.zip, ≈1.8 MB) ships with the docs, so no third-party CDN is involved.

    diff --git a/bin/build-reference.py b/bin/build-reference.py new file mode 100644 index 000000000..bc95c8991 --- /dev/null +++ b/bin/build-reference.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Generates docs/reference/.html for components not already hand-written. +Pulls catalog data from _docs_components.py and emits the concept-guide shape: +lede + install + context paragraphs + minimal example + refinements + pitfalls + see also. + +The hand-written reference pages (html, zip) are skipped — they live as +authored HTML files and we don't overwrite them. +""" + +import json +import os +import re +import sys +from html import escape as h, unescape + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _docs_components import COMPONENTS, COMPONENT_RELATIONS, CREDITS + +DOCS = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'docs', 'reference') +EXPECTED_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '_expected_outputs.json') +ASSET_VERSION = '20260429-rewrite' + +EXPECTED = {} +if os.path.exists(EXPECTED_PATH): + with open(EXPECTED_PATH) as f: + EXPECTED = {tuple(k.split('::')): v for k, v in json.load(f).items()} + +# Skip the hand-written ones. +SKIP = {'html', 'zip'} + +PAGE_HEAD = ''' + + + + +{title} — PHP Toolkit reference + + + + + + + +
    +\tPHP Toolkit +\t +
    + +
    +''' + +PAGE_FOOT = '''\t
    +
    + + + + +''' + + +def slugify(text): + return re.sub(r'[^\w\s-]', '', text.lower()).strip().replace(' ', '-') + + +def split_pitfalls(body_html): + """Pull out paragraphs that begin with 'Footgun:' or 'Gotcha:' and return them + as separate pitfall callouts. Return (rest_html, [pitfall_html, ...]).""" + pitfalls = [] + rest = [] + for chunk in re.findall(r'

    .*?

    ', body_html, flags=re.DOTALL): + plain = re.sub(r'<[^>]+>', '', chunk).strip() + if plain.lower().startswith(('footgun', 'gotcha')): + inner = chunk[3:-4] # strip

    ...

    + inner = re.sub(r'^(Footgun|Gotcha)[^<]*\s*[—:.\s]*', '', inner) + inner = re.sub(r'^(Footgun|Gotcha)[^a-z<]*', '', inner) + pitfalls.append(inner.strip()) + else: + rest.append(chunk) + return ''.join(rest), pitfalls + + +def snippet_block(slug, name, code, runnable=True): + safe = code.rstrip().replace('\n{expected_safe}\n\n' + ) + runnable_attr = '' if runnable else ' runnable="false"' + return ( + f'\n' + f'\n' + f'{expected_block}' + f'\n' + ) + + +def render_example(slug, snippet): + name, code = snippet[0], snippet[1] + runnable = len(snippet) < 3 or snippet[2] + return snippet_block(slug, name, code, runnable) + + +def sidebar(current_slug): + items = [] + for slug, title, _, _, _ in COMPONENTS: + is_legacy = slug in SKIP or slug in { + 'bytestream', 'filesystem', 'blockparser', 'markdown', 'xml', 'encoding', + 'dataliberation', 'git', 'merge', 'httpclient', 'httpserver', 'corsproxy', + 'cli', 'polyfill', 'blueprints', 'coding-standards', + } + # Reference page exists for skipped (handwritten) and the ones we generate here. + href = f'{slug}.html' + cls = ' class="current"' if slug == current_slug else '' + items.append(f'\t\t\t{h(title)}') + return ( + '\t\n' + ) + + +def render_component(slug, title, lede, install, sections): + # Separate the "Why this exists" intro from the worked sections. + purpose_html = '' + pitfalls_from_purpose = [] + usage = sections + if sections and sections[0][0].lower() == 'why this exists': + _, body, _ = sections[0] + purpose_html, pitfalls_from_purpose = split_pitfalls(unescape(body or '')) + usage = sections[1:] + + out = [PAGE_HEAD.format( + title=h(title), + description=h(re.sub(r'<[^>]+>', '', lede)), + asset_version=ASSET_VERSION, + )] + out.append(sidebar(slug)) + out.append('\t
    \n\n') + out.append(f'

    {h(title)}

    \n\n') + out.append(f'

    {lede}

    \n\n') + if install: + out.append(f'
    composer require {h(install)}
    \n\n') + if slug in CREDITS: + title_credit, body_credit = CREDITS[slug] + out.append( + '\n\n' + ) + if purpose_html: + out.append(unescape(purpose_html) + '\n\n') + + # Worked examples + accumulated pitfalls. + pitfalls = list(pitfalls_from_purpose) + minimal_emitted = False + for heading, body_html, snippet in usage: + # Pull pitfalls out of section body too. + rest, found = split_pitfalls(unescape(body_html or '')) + pitfalls.extend(found) + h2 = heading + if not minimal_emitted and snippet: + h2 = 'A minimal example' + minimal_emitted = True + elif snippet: + h2 = f'Refinement: {heading[0].lower() + heading[1:]}' if heading else heading + out.append(f'

    {h(h2)}

    \n\n') + if rest: + out.append(rest + '\n\n') + if snippet: + out.append(render_example(slug, snippet) + '\n') + + if pitfalls: + out.append('

    Pitfalls

    \n\n') + for p in pitfalls: + out.append(f'\n\n') + + related = COMPONENT_RELATIONS.get(slug, ()) + if related: + out.append('

    See also

    \n\n') + out.append('\n\n') + + out.append(PAGE_FOOT) + return ''.join(out) + + +def main(): + os.makedirs(DOCS, exist_ok=True) + for slug, title, lede, install, sections in COMPONENTS: + if slug in SKIP: + continue + out = render_component(slug, title, lede, install, sections) + path = os.path.join(DOCS, f'{slug}.html') + with open(path, 'w') as f: + f.write(out) + print(f'wrote reference/{slug}.html') + + +if __name__ == '__main__': + main() diff --git a/bin/run-snippets.py b/bin/run-snippets.py new file mode 100755 index 000000000..950f37747 --- /dev/null +++ b/bin/run-snippets.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Runs every PHP snippet in bin/_docs_components.py against the local +toolkit (`composer install` first, so vendor/autoload.php exists) and +captures stdout. Used in two ways: + + bin/run-snippets.py --update Regenerate bin/_expected_outputs.json + from the snippets that ran successfully. + bin/run-snippets.py --check Run every snippet, compare against the + committed JSON. Exit nonzero on drift. + Used by .github/workflows/snippet-tests.yml. + +Snippets reference '/wordpress/wp-content/php-toolkit/vendor/autoload.php' — +the path that exists inside Playground. The runner rewrites that to the +repo's local vendor/autoload.php before executing. + +Snippets marked non-runnable in the catalog are skipped. Snippets that need +WordPress, network access, or a listening TCP port may run locally but avoid +committing expected output because their stdout is environment-dependent. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile + +THIS = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(THIS) +sys.path.insert(0, THIS) +from _docs_components import COMPONENTS # noqa: E402 + +VENDOR_AUTOLOAD = os.path.join(ROOT, 'vendor', 'autoload.php') +EXPECTED_PATH = os.path.join(THIS, '_expected_outputs.json') + +# Snippets that can run but whose output isn't stable (real network, timestamps, +# host-specific values). They're verified to exit 0 but their stdout isn't +# captured into the JSON, so the docs page boots Playground at click time. +NO_EXPECTED = { + ('httpclient', 'get.php'), + ('httpclient', 'post.php'), + ('httpclient', 'progress.php'), + ('httpclient', 'sliding-window.php'), + ('httpclient', 'resume-download.php'), + ('httpclient', 'stream-unzip.php'), + ('httpclient', 'fan-out.php'), + ('httpclient', 'stream-to-disk.php'), +} + +PLAYGROUND_AUTOLOAD = "/wordpress/wp-content/php-toolkit/vendor/autoload.php" + +# Tiny polyfill so WordPress-only globals don't break local runs. +# Injected after the autoload require so WP_Block_Parser exists. +LOCAL_PRELUDE = """ +if ( ! function_exists( 'parse_blocks' ) ) { +\tfunction parse_blocks( $content ) { +\t\treturn ( new WP_Block_Parser() )->parse( $content ); +\t} +} +""" + + +def rewrite(code): + code = code.replace(PLAYGROUND_AUTOLOAD, VENDOR_AUTOLOAD) + match = re.search(r"require\s+'[^']*vendor/autoload\.php';", code) + if match: + insert_at = match.end() + code = code[:insert_at] + LOCAL_PRELUDE + code[insert_at:] + return code + + +def run_one(code, timeout=15): + with tempfile.NamedTemporaryFile(suffix='.php', mode='w', delete=False) as f: + f.write(rewrite(code)) + path = f.name + try: + proc = subprocess.run( + ['php', '-d', 'display_errors=stderr', path], + capture_output=True, text=True, timeout=timeout, + ) + return proc.returncode, proc.stdout, proc.stderr + except subprocess.TimeoutExpired: + return -1, '', f'TIMEOUT after {timeout}s' + finally: + try: + os.unlink(path) + except OSError: + pass + + +def normalize(text): + """Strip noise that varies between runs (tempfile names, timestamps).""" + # tempnam paths + text = re.sub(r'/tmp/\w+\.zip', '/tmp/.zip', text) + text = re.sub(r'(/tmp/\w+)(\.epub|\.tmp\.[a-f0-9]+)?', r'/tmp/\2', text) + text = re.sub(r'sys_get_temp_dir\(\) \. \'/[^\']+', "sys_get_temp_dir() . '/", text) + # uniqid suffixes from sys_get_temp_dir paths in code + text = re.sub(r'/(toolkit|atomic|copytree|big|orig|repacked|app|book|demo|sample|hash|gz|dl)-[a-f0-9]+', r'/\1-XXXXXX', text) + # Random nonces / hex strings + text = re.sub(r'\bnonce(?:: |=")([0-9a-f]{16})"?', lambda m: m.group(0).replace(m.group(1), ''), text) + text = re.sub(r'\bcommit: [0-9a-f]{40}\b', 'commit: ', text) + text = re.sub(r'\bHEAD:\s+[0-9a-f]{40}', 'HEAD: ', text) + text = re.sub(r'\boid: [0-9a-f]{40}\b', 'oid: ', text) + text = re.sub(r'merge head: [0-9a-f]{40}', 'merge head: ', text) + text = re.sub(r'\b[a-f0-9]{7} ', ' ', text) + # Memory numbers + text = re.sub(r'Peak memory: [\d.]+ MB', 'Peak memory: MB', text) + return text + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--update', action='store_true', help='Regenerate _expected_outputs.json') + ap.add_argument('--check', action='store_true', help='Verify against _expected_outputs.json') + ap.add_argument('--filter', default=None, help='Only run snippets whose slug or filename match this substring') + args = ap.parse_args() + + if not args.update and not args.check: + args.check = True + + if not os.path.exists(VENDOR_AUTOLOAD): + print(f'ERROR: {VENDOR_AUTOLOAD} not found. Run `composer install` first.', file=sys.stderr) + sys.exit(2) + + existing = {} + if os.path.exists(EXPECTED_PATH): + with open(EXPECTED_PATH) as f: + existing = {tuple(k.split('::')): v for k, v in json.load(f).items()} + + new = {} + failures = [] + skipped = 0 + matched = 0 + drift = [] + + for slug, _, _, _, sections in COMPONENTS: + for heading, _, snippet in sections: + if not snippet: + continue + filename, code = snippet[0], snippet[1] + runnable = len(snippet) < 3 or snippet[2] + if not runnable: + continue + if args.filter and args.filter not in slug and args.filter not in filename: + continue + rc, stdout, stderr = run_one(code) + if rc != 0: + # Snippet can't run locally — leave it out of JSON. The docs + # site will boot Playground for it at click time. + failures.append((slug, filename, stderr.strip().splitlines()[:2])) + skipped += 1 + continue + + key = (slug, filename) + if key in NO_EXPECTED: + # Ran successfully but we don't compare output. Don't store. + matched += 1 + continue + + normalized = normalize(stdout) + new[key] = normalized + + if args.check: + expected = existing.get(key) + if expected is None: + drift.append((slug, filename, 'NEW (run --update to add)')) + elif normalize(expected) != normalized: + drift.append((slug, filename, 'OUTPUT CHANGED')) + else: + matched += 1 + else: + matched += 1 + + print(f'\nRan {matched + len(drift)} snippets; {skipped} couldn\'t run locally.') + for slug, filename, why in failures: + why_text = ' '.join(why) if why else '(no stderr)' + print(f' skip {slug}/{filename:<32} {why_text[:80]}') + if args.check: + for slug, filename, kind in drift: + print(f' DRIFT {slug}/{filename:<32} {kind}') + + if args.update: + joined = {f'{k[0]}::{k[1]}': v for k, v in sorted(new.items())} + with open(EXPECTED_PATH, 'w') as f: + json.dump(joined, f, indent=2, sort_keys=True) + f.write('\n') + print(f'\nWrote {len(joined)} expected outputs to {EXPECTED_PATH}') + sys.exit(0) + + if drift: + print(f'\n{len(drift)} snippet(s) drifted. Run `bin/run-snippets.py --update` to refresh.') + sys.exit(1) + print('\nAll snippets match expected outputs.') + + +if __name__ == '__main__': + main() diff --git a/components/BlockParser/README.md b/components/BlockParser/README.md index 2cf95fb97..fa0d7b2b6 100644 --- a/components/BlockParser/README.md +++ b/components/BlockParser/README.md @@ -1,218 +1,137 @@ # BlockParser -A standalone extraction of WordPress core's block parser. It takes a document containing WordPress block markup (`...`) and returns a structured array of parsed blocks with their attributes, inner HTML, inner blocks, and content interleaving. This is the same parser that powers `parse_blocks()` in WordPress core, packaged as an independent library with no WordPress dependency. +## Why this exists -## Installation - -``` -composer require wp-php-toolkit/blockparser -``` - -## Quick Start - -```php -$document = << -

    Welcome

    - +WordPress stores post content as annotated HTML. Instead of inventing a separate file format, it embeds block boundaries directly inside HTML comments: +```html -

    Hello from the block editor.

    +

    Hello, world.

    -HTML; -$parser = new WP_Block_Parser(); -$blocks = $parser->parse( $document ); - -foreach ( $blocks as $block ) { - if ( 'core/heading' === $block['blockName'] ) { - echo 'Found heading: ' . strip_tags( $block['innerHTML'] ); - // "Found heading: Welcome" - } -} + +
    + ``` -## Usage - -### Parsing a Document +Every WordPress editor, REST API response, and block renderer needs to turn that serialized markup into a structured tree. WordPress core ships `WP_Block_Parser` to do exactly that — but it's buried inside WordPress itself, tied to the full WordPress load. This component extracts it so you can parse block markup anywhere: CLI tools, build scripts, data-migration pipelines, standalone PHP apps — without booting WordPress. -Call `parse()` with any string containing block markup. It returns an array of block arrays, each with the following keys: +## How it works -```php -$parser = new WP_Block_Parser(); -$blocks = $parser->parse( $document ); - -// Each element in $blocks is an array: -// array( -// 'blockName' => 'core/paragraph', // Fully-qualified block name, or null for freeform HTML. -// 'attrs' => array(), // Attributes from the block comment delimiter. -// 'innerBlocks' => array(), // Nested blocks (same structure, recursive). -// 'innerHTML' => '

    Text

    ', // The HTML inside the block, with inner blocks removed. -// 'innerContent' => array( '

    Text

    ' ), // Interleaved HTML strings and null markers for inner block positions. -// ) -``` +The parser is a single-pass, stack-based scanner. It moves forward through the document looking for HTML comments that follow the block annotation pattern. When it finds an opening comment like ``, it: -### Block Types +1. Decodes the JSON attributes from the comment body. +2. Pushes a frame onto a stack, recording the block name, attributes, and the byte offset where the block started. +3. Keeps scanning, collecting the raw HTML between the opening and closing comments as `innerHTML`. +4. If it encounters another `` before the closing comment, it recurses — pushing a new frame for the inner block. +5. When it finds a closing comment (``), it pops the frame, attaches any collected inner blocks, and appends the completed block to its parent. -The parser recognizes three kinds of block tokens: +Freeform content between blocks — plain HTML with no block annotations — becomes a "classic block" with `blockName` set to `null`. -**Standard blocks** have an opener and closer: +The `innerContent` array is the most subtle part of the output. It interleaves child block positions with raw HTML chunks, letting renderers reconstruct the exact original layout. This is how the columns block describes which raw HTML wraps each inner column. -```php -$blocks = ( new WP_Block_Parser() )->parse( - '

    Hello

    ' -); -// $blocks[0]['blockName'] === 'core/paragraph' -// $blocks[0]['innerHTML'] === '

    Hello

    ' -``` +## Usage -**Self-closing (void) blocks** end with `/-→`: +### Parse a post's block content ```php -$blocks = ( new WP_Block_Parser() )->parse( - '' -); -// $blocks[0]['blockName'] === 'core/spacer' -// $blocks[0]['attrs'] === array( 'height' => '50px' ) -// $blocks[0]['innerHTML'] === '' -``` +use WordPress\BlockParser\WP_Block_Parser; -**Freeform HTML** is any content outside of block delimiters: +$parser = new WP_Block_Parser(); +$blocks = $parser->parse( $post_content ); -```php -$blocks = ( new WP_Block_Parser() )->parse( - '

    Just some HTML, no blocks here.

    ' -); -// $blocks[0]['blockName'] === null -// $blocks[0]['innerHTML'] === '

    Just some HTML, no blocks here.

    ' +foreach ( $blocks as $block ) { + echo $block['blockName']; // e.g. "core/paragraph" + echo $block['innerHTML']; // the raw HTML inside the block + // $block['attrs'] — decoded JSON attributes + // $block['innerBlocks'] — nested blocks (same structure, recursive) + // $block['innerContent'] — interleaved HTML chunks + child-block slots +} ``` -### Block Attributes +### Inspect block attributes -Attributes are encoded as JSON inside the block comment delimiter. The parser decodes them into a PHP associative array: +Attributes are encoded as JSON in the opening comment and decoded automatically: ```php -$blocks = ( new WP_Block_Parser() )->parse( - '' . - '
    ' . - '' -); - -$attrs = $blocks[0]['attrs']; -// array( -// 'id' => 123, -// 'sizeSlug' => 'large', -// 'linkDestination' => 'none', -// ) +$markup = '' + . '
    ...
    ' + . ''; + +$blocks = $parser->parse( $markup ); +echo $blocks[0]['attrs']['sizeSlug']; // "large" ``` -### Nested Blocks +### Walk a nested block tree -Blocks can contain other blocks. Inner blocks appear in the `innerBlocks` array, and `innerContent` interleaves the HTML fragments with `null` markers showing where each inner block was located: +Blocks can contain other blocks. The `innerBlocks` key holds them recursively: ```php -$document = << -
    - -
    - -

    Left column

    - -
    - - -
    - -

    Right column

    - -
    - -
    - -HTML; +function walk( array $blocks, int $depth = 0 ): void { + foreach ( $blocks as $block ) { + if ( $block['blockName'] === null ) { + continue; // skip freeform HTML between blocks + } + echo str_repeat( ' ', $depth ) . $block['blockName'] . "\n"; + walk( $block['innerBlocks'], $depth + 1 ); + } +} -$parser = new WP_Block_Parser(); -$blocks = $parser->parse( $document ); - -$columns = $blocks[0]; -// $columns['blockName'] === 'core/columns' -// count( $columns['innerBlocks'] ) === 2 - -$left_column = $columns['innerBlocks'][0]; -// $left_column['blockName'] === 'core/column' -// $left_column['innerBlocks'][0]['blockName'] === 'core/paragraph' - -// innerContent shows the interleaving of HTML and inner block positions: -// array( -// '
    \n', // HTML before first inner block -// null, // Position of first inner block (core/column) -// '\n', // HTML between inner blocks -// null, // Position of second inner block (core/column) -// '\n
    \n', // HTML after last inner block -// ) +walk( $parser->parse( $post_content ) ); +// core/columns +// core/column +// core/paragraph +// core/column +// core/image ``` -### Namespaced Blocks +### Reconstruct output using innerContent -The parser handles both core blocks (`wp:paragraph`) and namespaced third-party blocks (`wp:my-plugin/custom-block`). Block names without an explicit namespace are prefixed with `core/`: +The `innerContent` array lets you rebuild the original markup while swapping in rendered child blocks: ```php -$blocks = ( new WP_Block_Parser() )->parse( - '' . - '
    Great product!
    ' . - '' -); -// $blocks[0]['blockName'] === 'my-plugin/testimonial' -// $blocks[0]['attrs'] === array( 'author' => 'Jane' ) -``` +function render_block( array $block ): string { + $output = ''; + $child_index = 0; + + foreach ( $block['innerContent'] as $chunk ) { + if ( is_string( $chunk ) ) { + $output .= $chunk; + } else { + // null = "insert rendered child block here" + $output .= render_block( $block['innerBlocks'][ $child_index++ ] ); + } + } -### Error Recovery + return $output; +} +``` -The parser is designed to never fail. When it encounters malformed markup such as missing closers or mismatched block names, it produces a best-effort parse rather than returning an error: +### Find all blocks of a specific type ```php -// Missing closer -- the parser treats it as implicitly closed. -$blocks = ( new WP_Block_Parser() )->parse( - '

    No closer here' -); -// $blocks[0]['blockName'] === 'core/paragraph' -// $blocks[0]['innerHTML'] === '

    No closer here' -``` - -## API Reference +function find_blocks( array $blocks, string $name ): array { + $found = array(); + foreach ( $blocks as $block ) { + if ( $block['blockName'] === $name ) { + $found[] = $block; + } + $found = array_merge( $found, find_blocks( $block['innerBlocks'], $name ) ); + } + return $found; +} -### WP_Block_Parser +$images = find_blocks( $parser->parse( $post_content ), 'core/image' ); +``` -| Method | Description | -|--------|-------------| -| `parse( $document )` | Parse block markup and return an array of block structures | +## Block structure reference -### Block Structure (array keys) +Each parsed block is an associative array: | Key | Type | Description | |-----|------|-------------| -| `blockName` | `string\|null` | Fully-qualified name (e.g. `core/paragraph`), or `null` for freeform HTML | -| `attrs` | `array` | Block attributes decoded from the JSON in the comment delimiter | -| `innerBlocks` | `array` | Nested blocks, same structure recursively | -| `innerHTML` | `string` | HTML content with inner blocks stripped out | -| `innerContent` | `array` | Interleaved HTML strings and `null` markers for inner block positions | - -### WP_Block_Parser_Block - -| Property | Type | Description | -|----------|------|-------------| -| `$blockName` | `string\|null` | Block name | -| `$attrs` | `array\|null` | Block attributes | -| `$innerBlocks` | `array` | Nested block instances | -| `$innerHTML` | `string` | Inner HTML content | -| `$innerContent` | `array` | Interleaved content with `null` placeholders | - -## Attribution - -This component is extracted from [WordPress core](https://github.com/WordPress/wordpress-develop). The `WP_Block_Parser`, `WP_Block_Parser_Block`, and `WP_Block_Parser_Frame` classes are maintained as part of the WordPress block editor infrastructure. Licensed under GPL v2. - -## Requirements - -- PHP 7.2+ -- No external dependencies +| `blockName` | `string\|null` | Namespaced block name, e.g. `"core/paragraph"`. `null` for classic/freeform content between blocks. | +| `attrs` | `array` | Decoded JSON attributes from the opening comment. Empty array if none. | +| `innerBlocks` | `array` | Recursively parsed child blocks in order of appearance. | +| `innerHTML` | `string` | The full raw HTML between the opening and closing comments, including inner block markup verbatim. | +| `innerContent` | `array` | Interleaved array: strings are raw HTML chunks, `null` values mark positions where a child block from `innerBlocks` should be inserted. | diff --git a/components/Filesystem/README.md b/components/Filesystem/README.md index 17a605415..153d0e525 100644 --- a/components/Filesystem/README.md +++ b/components/Filesystem/README.md @@ -1,240 +1,141 @@ # Filesystem -A unified filesystem abstraction that lets you work with local disks, in-memory trees, SQLite-backed storage, and other backends through a single interface. Every implementation uses forward slashes as path separators regardless of the host OS, so code that works on Linux works identically on Windows and macOS. +## Why this exists -## Installation +PHP's built-in file functions (`file_get_contents`, `fopen`, `mkdir`, etc.) are tightly coupled to the local disk. That's fine for simple scripts, but it creates a real problem when you want to: -```bash -composer require wp-php-toolkit/filesystem -``` - -## Quick Start - -```php -use WordPress\Filesystem\InMemoryFilesystem; +- **Test code without touching the disk.** Unit tests that create real files are slow, fragile, and leave cleanup responsibilities behind. +- **Work with non-disk storage.** WordPress Playground runs entirely in the browser using a virtual filesystem backed by a SQLite database. Your code needs to work the same way against both a real disk and an in-memory tree. +- **Operate on ZIP archives as if they were directories.** Instead of extracting first and then reading, you want to walk a ZIP file the same way you'd walk a folder. +- **Stay portable across operating systems.** Windows uses backslashes; everything else uses forward slashes. Code that hardcodes separators breaks on the other platform. -$fs = InMemoryFilesystem::create(); -$fs->mkdir( '/docs' ); -$fs->put_contents( '/docs/readme.txt', 'Hello, world!' ); -echo $fs->get_contents( '/docs/readme.txt' ); // "Hello, world!" -``` +This component defines a single `Filesystem` interface and several implementations behind it. Write your code against the interface once, and it works against any backend. -## Usage - -### Local Filesystem - -`LocalFilesystem` wraps the real disk. Pass a root directory to `create()` and all paths are resolved relative to it. - -```php -use WordPress\Filesystem\LocalFilesystem; +## How it works -$fs = LocalFilesystem::create( '/var/www/mysite' ); +The `Filesystem` interface defines the operations every backend must support: listing directories, reading and writing files, checking existence, copying, renaming, deleting. Implementations handle the translation to whatever storage mechanism is underneath. -// Write and read files -$fs->put_contents( '/config.json', '{"debug": true}' ); -echo $fs->get_contents( '/config.json' ); // '{"debug": true}' +All paths use forward slashes (`/`) regardless of OS. On Windows, the `LocalFilesystem` translates them to backslashes internally, but your code never sees that. -// Directory operations -$fs->mkdir( '/uploads/2024', array( 'recursive' => true ) ); -$fs->put_contents( '/uploads/2024/photo.txt', 'image data here' ); +Reads and writes are stream-based under the hood. `open_read_stream()` returns a handle you can read in chunks; `open_write_stream()` gives you a handle to write to. `get_contents()` and `put_contents()` are convenience wrappers that read or write the entire file at once. -// List directory contents -$entries = $fs->ls( '/uploads/2024' ); // ['photo.txt'] +The `FilesystemVisitor` handles recursive tree traversal, emitting events for each directory and file it encounters. -// Check paths -$fs->is_dir( '/uploads' ); // true -$fs->is_file( '/config.json' ); // true -$fs->exists( '/missing' ); // false -``` +### The implementations -Without a root argument, `LocalFilesystem::create()` defaults to the system root (`/` on Unix, the system drive on Windows). +**`LocalFilesystem`** — wraps PHP's built-in file functions. Works on the actual disk. -### In-Memory Filesystem +**`InMemoryFilesystem`** — stores everything in a PHP array. Fast, zero I/O, perfect for tests and ephemeral scratch space. -`InMemoryFilesystem` stores everything in PHP arrays. It is useful for tests, temporary processing, and anywhere you need a fast, disposable filesystem. +**`SQLiteFilesystem`** — stores files in a SQLite database. Used by WordPress Playground to persist a WordPress installation in a single database file that can be serialized, snapshotted, and restored. -```php -use WordPress\Filesystem\InMemoryFilesystem; +**`ZipFilesystem`** (from the Zip component) — mounts a ZIP archive as a read-only directory tree. -$fs = InMemoryFilesystem::create(); +**`UploadedFilesystem`** — wraps another filesystem and tracks which paths were written, for auditing what an operation produced. -$fs->mkdir( '/src/components', array( 'recursive' => true ) ); -$fs->put_contents( '/src/components/button.php', 'put_contents( '/src/components/form.php', 'ls( '/src/components' ); // ['button.php', 'form.php'] -``` +Many factory methods wrap a filesystem in a `ChrootLayer`, which jails all path operations to a specific root directory. This prevents code from accidentally escaping to `/` and makes it safe to hand a filesystem object to untrusted code. -### SQLite Filesystem +## Usage -`SQLiteFilesystem` persists files and directories in a SQLite database. It requires the `sqlite3` PHP extension (dev-only dependency, not required by the library at runtime). +### Read a file ```php -use WordPress\Filesystem\SQLiteFilesystem; - -// In-memory SQLite database -$fs = SQLiteFilesystem::create( ':memory:' ); +use WordPress\Filesystem\LocalFilesystem; -// Or persist to a file -$fs = SQLiteFilesystem::create( '/tmp/my-files.sqlite' ); +$fs = new LocalFilesystem( '/var/www/html' ); -$fs->mkdir( '/data' ); -$fs->put_contents( '/data/report.csv', 'id,name\n1,Alice' ); -echo $fs->get_contents( '/data/report.csv' ); +if ( $fs->is_file( '/wp-config.php' ) ) { + $contents = $fs->get_contents( '/wp-config.php' ); +} ``` -### File and Directory Operations - -All filesystem implementations share the same interface. These operations work identically across backends. +### Write a file ```php -// Rename (move) a file -$fs->put_contents( '/old-name.txt', 'content' ); -$fs->rename( '/old-name.txt', '/new-name.txt' ); - -// Copy a file -$fs->put_contents( '/source.txt', 'content' ); -$fs->copy( '/source.txt', '/dest.txt' ); - -// Copy a directory tree -$fs->mkdir( '/src/lib', array( 'recursive' => true ) ); -$fs->put_contents( '/src/lib/utils.php', 'copy( '/src', '/backup', array( 'recursive' => true ) ); -echo $fs->get_contents( '/backup/lib/utils.php' ); // 'rm( '/dest.txt' ); -$fs->rmdir( '/backup', array( 'recursive' => true ) ); +$fs->put_contents( '/uploads/hello.txt', 'Hello, world.' ); ``` -### Streaming Reads and Writes - -Every filesystem can open byte streams for reading and writing. This integrates with the ByteStream component for chunk-based processing of large files. +### List a directory ```php -// Write via stream -$writer = $fs->open_write_stream( '/output.bin' ); -$writer->append_bytes( 'chunk 1' ); -$writer->append_bytes( 'chunk 2' ); -$writer->close_writing(); - -// Read via stream -$reader = $fs->open_read_stream( '/output.bin' ); -$contents = $reader->consume_all(); -$reader->close_reading(); +foreach ( $fs->ls( '/wp-content/plugins' ) as $name ) { + echo $name . "\n"; // plugin directory names only, not full paths +} ``` -### Copying Between Filesystems +### Use an in-memory filesystem for tests -The `copy_between_filesystems()` function streams data from one filesystem to another, even across different backends. +Because your code accepts a `Filesystem` interface, you can swap in `InMemoryFilesystem` in tests without changing anything else: ```php -use WordPress\Filesystem\LocalFilesystem; use WordPress\Filesystem\InMemoryFilesystem; -use function WordPress\Filesystem\copy_between_filesystems; - -$local = LocalFilesystem::create( '/var/www/site' ); -$memory = InMemoryFilesystem::create(); +$fs = new InMemoryFilesystem(); +$fs->put_contents( '/config.json', json_encode( [ 'debug' => true ] ) ); -// Copy an entire directory tree from disk into memory -copy_between_filesystems( array( - 'source_filesystem' => $local, - 'source_path' => '/wp-content/themes/flavor', - 'target_filesystem' => $memory, - 'target_path' => '/theme', -) ); - -echo $memory->get_contents( '/theme/style.css' ); +// Pass $fs to the code under test — it never touches the real disk. +$result = my_config_loader( $fs ); ``` -### Traversing a Filesystem - -`FilesystemVisitor` walks a filesystem tree depth-first, emitting enter and exit events for each directory along with its files. +### Walk a directory tree ```php use WordPress\Filesystem\Visitor\FilesystemVisitor; -use WordPress\Filesystem\Visitor\FileVisitorEvent; -$visitor = new FilesystemVisitor( $fs ); +$visitor = new FilesystemVisitor( $fs, '/' ); while ( $visitor->next() ) { $event = $visitor->get_event(); - if ( $event->is_entering() ) { - echo "Entering: " . $event->dir . "\n"; - foreach ( $event->files as $file ) { - echo " File: " . $file . "\n"; - } - } + echo $event->get_path() . ( $event->is_dir() ? '/' : '' ) . "\n"; } ``` -### Path Helpers +### Stream large files -The Filesystem component provides Unix-style path utilities that behave consistently on every OS. +For large files, streaming avoids loading everything into memory at once: ```php -use function WordPress\Filesystem\wp_join_unix_paths; -use function WordPress\Filesystem\wp_unix_dirname; -use function WordPress\Filesystem\wp_unix_path_resolve_dots; +$read_stream = $fs->open_read_stream( '/large-export.sql' ); +$write_stream = $fs->open_write_stream( '/large-export-copy.sql' ); -// Join path segments, collapsing duplicate slashes -echo wp_join_unix_paths( '/var/www', 'site', 'index.php' ); -// "/var/www/site/index.php" +while ( ! $read_stream->is_finished() ) { + $chunk = $read_stream->read( 65536 ); // 64 KB at a time + $write_stream->write( $chunk ); +} + +$read_stream->close(); +$write_stream->close(); +``` -// Get the parent directory -echo wp_unix_dirname( '/var/www/site/index.php' ); -// "/var/www/site" +### Copy files between different backends -// Resolve . and .. segments -echo wp_unix_path_resolve_dots( '/var/www/site/../other/./page.php' ); -// "/var/www/other/page.php" +Because every backend speaks the same interface, you can copy between them directly: + +```php +use WordPress\Filesystem\LocalFilesystem; +use WordPress\Filesystem\InMemoryFilesystem; +use WordPress\Filesystem\Visitor\FilesystemVisitor; + +$local = new LocalFilesystem( '/var/www/html' ); +$memory = new InMemoryFilesystem(); + +// Copy everything from disk to memory. +$visitor = new FilesystemVisitor( $local, '/' ); +while ( $visitor->next() ) { + $event = $visitor->get_event(); + $path = $event->get_path(); + if ( $event->is_file() ) { + $memory->put_contents( $path, $local->get_contents( $path ) ); + } elseif ( $event->is_dir() ) { + $memory->mkdir( $path ); + } +} ``` -## API Reference - -### Filesystem Interface - -All implementations provide these methods: - -| Method | Description | -|---|---| -| `ls( $dir )` | List entries in a directory | -| `is_dir( $path )` | Check if path is a directory | -| `is_file( $path )` | Check if path is a file | -| `exists( $path )` | Check if path exists | -| `mkdir( $path, $options )` | Create a directory. Use `['recursive' => true]` for nested paths | -| `rm( $path )` | Remove a file | -| `rmdir( $path, $options )` | Remove a directory. Use `['recursive' => true]` for non-empty dirs | -| `put_contents( $path, $data )` | Write a string to a file | -| `get_contents( $path )` | Read a file into a string | -| `open_read_stream( $path )` | Open a `ByteReadStream` for chunk-based reading | -| `open_write_stream( $path )` | Open a `ByteWriteStream` for chunk-based writing | -| `copy( $from, $to, $options )` | Copy a file or directory | -| `rename( $from, $to )` | Move/rename a file or directory | - -### Implementations - -| Class | Description | -|---|---| -| `LocalFilesystem` | Wraps the real disk via `LocalFilesystem::create( $root )` | -| `InMemoryFilesystem` | Array-backed filesystem via `InMemoryFilesystem::create()` | -| `SQLiteFilesystem` | SQLite-backed filesystem via `SQLiteFilesystem::create( $path )` | -| `UploadedFilesystem` | Read-only filesystem for handling REST API file uploads | - -Other packages extend this interface with additional backends: `GitFilesystem` (from the Git component) and `ZipFilesystem` (from the Zip component). - -### Helper Functions - -| Function | Description | -|---|---| -| `wp_join_unix_paths( ...$segments )` | Join path segments with forward slashes | -| `wp_unix_dirname( $path )` | Get parent directory (Unix semantics on all OSes) | -| `wp_unix_path_resolve_dots( $path )` | Resolve `.` and `..` segments | -| `wp_unix_sys_get_temp_dir()` | Like `sys_get_temp_dir()` but always uses forward slashes | -| `copy_between_filesystems( $args )` | Stream data between two filesystem instances | -| `pipe_stream( $from, $to )` | Pipe a read stream into a write stream | - -## Requirements - -- PHP 7.2+ -- No external dependencies (SQLiteFilesystem requires the `sqlite3` extension, which is a dev-only dependency) +## Path conventions + +- Always use forward slashes: `/wp-content/uploads/photo.jpg`. +- Paths are absolute from the filesystem root. The root itself is `/`. +- On Windows, `LocalFilesystem` converts slashes internally; you never need to use `DIRECTORY_SEPARATOR`. +- `ChrootLayer` jails all paths to the configured root. A path of `/` inside a chrooted filesystem refers to the configured root directory on disk, not the actual system root. diff --git a/components/Git/README.md b/components/Git/README.md index ed61f56d2..0cd7213d1 100644 --- a/components/Git/README.md +++ b/components/Git/README.md @@ -1,229 +1,129 @@ # Git -A pure PHP implementation of a Git client and server. It can create repositories, read and write objects, commit files, manage branches, diff, merge, and communicate with remote servers over HTTP -- all without shelling out to the `git` binary or requiring any native extensions. +## Why this exists -## Installation +Git is typically used through the `git` binary — a compiled C program that reads and writes the repository on disk. That's perfect for most development workflows, but it breaks down in a few important scenarios: -```bash -composer require wp-php-toolkit/git -``` +- **Serverless and sandboxed environments.** WordPress Playground runs PHP entirely in the browser via WebAssembly. There is no OS, no filesystem, no ability to exec a subprocess. Yet Playground needs to clone, commit, and push WordPress installations as Git repositories. +- **Programmatic repository manipulation.** Sometimes you want to create commits, rewrite history, or sync files between repositories entirely from PHP — without spawning a shell process or depending on the `git` binary being installed. +- **Embedding Git into a PHP application.** Build tools, deployment systems, and migration scripts that want to produce or consume Git repositories without a compile-time dependency on libgit2 or similar native libraries. -## Quick Start +This component implements the Git object model, pack protocol, and HTTP smart transport in pure PHP. It can talk to any standard Git remote — GitHub, GitLab, Gitea, self-hosted — using only PHP's HTTP client. -```php -use WordPress\Filesystem\InMemoryFilesystem; -use WordPress\Git\GitRepository; -use WordPress\Git\Model\Commit; - -// Create a repository backed by an in-memory filesystem. -// You can also use a local filesystem for on-disk storage. -$repo = new GitRepository( InMemoryFilesystem::create() ); - -// Commit files directly -- the repository builds the -// blob, tree, and commit objects for you. -$commit_oid = $repo->commit( array( - 'updates' => array( - 'README.md' => '# My Project', - 'src/hello-world.php' => 'read_object_by_path( '/README.md' )->consume_all(); -// "# My Project" -``` +## How it works -## Usage +Git's data model is simpler than it looks. Everything is content-addressed: the SHA-1 hash of an object's content is its name. There are four object types: -### Creating and reading objects +- **blob** — file content, nothing else. +- **tree** — a directory listing: each entry maps a filename to either a blob hash (file) or another tree hash (subdirectory). +- **commit** — a snapshot: it points to a tree (the root of the working directory), zero or more parent commit hashes, and metadata like the author and message. +- **tag** — a named pointer to another object (usually a commit). + +When you commit a file, Git stores the file content as a blob, builds a tree structure from the directory layout, and creates a commit object that records which tree represents the project state at that moment. Branches are just named pointers to commit hashes stored in `refs/heads/`. + +`GitRepository` handles all of this. Give it a `Filesystem` object to use as backing storage, and it reads and writes Git objects directly into the `.git` directory structure. `GitRemote` handles the HTTP smart protocol — fetching a list of remote refs, downloading pack files, uploading missing objects. + +`GitFilesystem` wraps a `GitRepository` and exposes the contents of a specific commit through the standard `Filesystem` interface, so the rest of your code doesn't need to know it's reading from a Git object store. + +## Usage -Every piece of data in Git is an object identified by its SHA-1 hash. You can create blobs, trees, and commits directly: +### Create a new repository and make a commit ```php -use WordPress\Filesystem\InMemoryFilesystem; use WordPress\Git\GitRepository; +use WordPress\Filesystem\InMemoryFilesystem; -$repo = new GitRepository( InMemoryFilesystem::create() ); +$fs = new InMemoryFilesystem(); +$repo = new GitRepository( $fs ); +$repo->init(); -// Store a blob and get its SHA-1 hash. -$blob_oid = $repo->add_object( 'blob', 'Hello, world!' ); -// "5dd01c177f5d7d1be5346a5bc18a569a7410c2ef" +// Stage a file by writing it to the working directory... +$fs->put_contents( '/hello.txt', 'Hello, world.' ); -// Read it back. -$reader = $repo->read_object( $blob_oid ); -$reader->pull( 8096 ); -$data = $reader->peek( 8096 ); -// "Hello, world!" +// ...then commit. +$repo->stage_files( array( 'hello.txt' ) ); +$repo->commit( 'Initial commit', 'Author Name', 'author@example.com' ); ``` -### Committing files - -The `commit()` method handles building the tree hierarchy, creating blob objects, and wiring up parent commits automatically: +### Read a file from a specific commit ```php -use WordPress\Filesystem\InMemoryFilesystem; -use WordPress\Git\GitRepository; +use WordPress\Git\GitFilesystem; + +// Mount the HEAD commit as a filesystem. +$git_fs = new GitFilesystem( $repo, 'HEAD' ); -$repo = new GitRepository( InMemoryFilesystem::create() ); - -// First commit. -$first_oid = $repo->commit( array( - 'updates' => array( - 'dir1/file1.txt' => 'Initial content of file1', - 'dir2/file2.txt' => 'Initial content of file2', - ), -) ); - -// Second commit -- only the changed files are updated. -$second_oid = $repo->commit( array( - 'updates' => array( - 'dir1/file1.txt' => 'Updated file1', - ), -) ); - -// Delete a file in a commit. -$third_oid = $repo->commit( array( - 'deletes' => array( 'dir2/file2.txt' ), -) ); +$contents = $git_fs->get_contents( '/hello.txt' ); +// "Hello, world." ``` -### Branch management +### Clone from a remote ```php -use WordPress\Filesystem\InMemoryFilesystem; use WordPress\Git\GitRepository; +use WordPress\Git\GitRemote; +use WordPress\Filesystem\LocalFilesystem; -$repo = new GitRepository( InMemoryFilesystem::create() ); -$initial_oid = $repo->commit( array( - 'updates' => array( 'file.txt' => 'initial' ), -) ); - -// Create a new branch pointing at the current commit. -$repo->create_branch( 'refs/heads/feature', $initial_oid ); - -// Switch to it. -$repo->checkout( 'refs/heads/feature' ); +$fs = new LocalFilesystem( '/tmp/my-clone' ); +$repo = new GitRepository( $fs ); +$repo->init(); -// Commit on the new branch. -$repo->commit( array( - 'updates' => array( 'file.txt' => 'changed on feature' ), -) ); - -// Switch back to the default branch. -$repo->checkout( 'refs/heads/trunk' ); +$repo->add_remote( 'origin', 'https://github.com/WordPress/wordpress-develop' ); +$remote = $repo->get_remote_client( 'origin' ); -// Read the current branch tip hash. -$head_hash = $repo->get_branch_tip( 'HEAD' ); +// Fetch the default branch. +$remote->fetch( 'refs/heads/trunk' ); ``` -### Merging +### Push to a remote ```php -$repo->checkout( 'refs/heads/trunk' ); -$result = $repo->merge( 'refs/heads/feature' ); - -// $result['new_head'] -- the hash of the merge commit -// $result['conflicts'] -- array of conflicting paths (empty if none) +$remote = $repo->get_remote_client( 'origin' ); +$remote->push( 'refs/heads/my-branch' ); ``` -### Using GitFilesystem - -`GitFilesystem` wraps a `GitRepository` with the standard `Filesystem` interface, so you can read and write files as if working with a regular filesystem. Each write creates a new commit. +### Read the commit log ```php -use WordPress\Filesystem\InMemoryFilesystem; -use WordPress\Git\GitFilesystem; -use WordPress\Git\GitRepository; -use WordPress\Git\Model\Commit; - -$repo = new GitRepository( InMemoryFilesystem::create() ); -$repo->commit( array( - 'updates' => array( - 'README.md' => 'Hello, world!', - 'subdirectory/hello-world.txt' => 'Hello, world!', - ), -) ); - -$fs = GitFilesystem::create( $repo ); +$head = $repo->get_head(); +$commit = $repo->read_commit( $head ); -$fs->ls( '/' ); -// ['README.md', 'subdirectory'] +while ( $commit !== null ) { + echo $commit->message . "\n"; + echo ' by ' . $commit->author_name . ' <' . $commit->author_email . ">\n"; -$fs->is_file( '/README.md' ); // true -$fs->is_dir( '/subdirectory' ); // true -$fs->get_contents( '/README.md' ); // "Hello, world!" - -// Writing creates a new commit automatically. -$fs->put_contents( '/new-file.txt', 'content' ); - -// Rename a directory. -$fs->rename( '/subdirectory', '/renamed' ); + $parent_hash = $commit->parent_hash; + $commit = $parent_hash ? $repo->read_commit( $parent_hash ) : null; +} ``` -### Working with remotes +### Diff two commits ```php -use WordPress\Filesystem\InMemoryFilesystem; -use WordPress\Git\GitRepository; +$changes = $repo->diff( $commit_hash_a, $commit_hash_b ); -$repo = new GitRepository( InMemoryFilesystem::create() ); -$repo->add_remote( 'origin', 'https://github.com/user/repo' ); +foreach ( $changes as $path => $change ) { + echo $change['status'] . ' ' . $path . "\n"; + // 'A' = added, 'M' = modified, 'D' = deleted +} +``` -$remote = $repo->get_remote_client( 'origin' ); +### Use GitFilesystem anywhere a Filesystem is expected -// List remote refs. -$refs = $remote->ls_refs( 'refs/heads/' ); +Because `GitFilesystem` implements the `Filesystem` interface, you can pass it to any code that operates on a filesystem — including `ZipEncoder` to package a commit as a ZIP file: -// Pull a branch. -$remote->pull( 'refs/heads/trunk' ); +```php +use WordPress\Git\GitFilesystem; +use WordPress\Zip\ZipEncoder; -// Push local changes. -$remote->push( 'trunk' ); +$git_fs = new GitFilesystem( $repo, $commit_hash ); +$encoder = new ZipEncoder( $output_stream ); +$encoder->append_from_filesystem( $git_fs, '/' ); +$encoder->finish(); ``` -## API Reference - -### GitRepository - -| Method | Description | -|---|---| -| `__construct( Filesystem $fs )` | Create a repository backed by a filesystem | -| `add_object( $type, $content )` | Store a blob, tree, or commit; returns its SHA-1 hash | -| `read_object( $oid )` | Read an object by hash; returns a stream with `consume_all()` and `as_commit()` / `as_tree()` | -| `has_object( $oid )` | Check whether an object exists locally | -| `find_hash_by_path( $path, $commit )` | Resolve a file path to its object hash | -| `read_object_by_path( $path, $commit )` | Read a file's content by path | -| `commit( $options )` | Create a commit with `'updates'`, `'deletes'`, and `'move_trees'` | -| `create_branch( $name, $oid )` | Create a new branch | -| `checkout( $branch_or_hash )` | Switch HEAD to a branch or commit | -| `get_branch_tip( $name )` | Get the commit hash a branch points to | -| `set_branch_tip( $name, $oid )` | Point a branch at a specific commit | -| `merge( $branch_name, $options )` | Three-way merge; returns `['new_head' => ..., 'conflicts' => [...]]` | -| `diff_commits( $hash1, $hash2 )` | Diff two commits | -| `add_remote( $name, $url )` | Register a remote | -| `get_remote_client( $name )` | Get a `GitRemote` for push/pull operations | - -### GitFilesystem - -| Method | Description | -|---|---| -| `GitFilesystem::create( $repo )` | Wrap a repository with the Filesystem interface | -| `ls( $path )` | List directory entries | -| `is_file( $path )` / `is_dir( $path )` | Check entry type | -| `get_contents( $path )` | Read file contents | -| `put_contents( $path, $data )` | Write a file (creates a commit) | -| `rename( $from, $to )` | Rename a file or directory | -| `rm( $path )` / `rmdir( $path )` | Delete a file or directory | - -### Model classes - -| Class | Key properties | -|---|---| -| `Commit` | `$hash`, `$tree`, `$parents`, `$author`, `$message` | -| `Tree` | `$entries` (map of name to `TreeEntry`) | -| `TreeEntry` | `$mode`, `$name`, `$hash`; constants `FILE_MODE_REGULAR_NON_EXECUTABLE`, `FILE_MODE_DIRECTORY` | - -## Requirements - -- PHP 7.2+ -- No external dependencies (no `git` binary required) +## Architecture notes + +Git object storage uses a two-level directory scheme: objects live in `.git/objects/ab/cdef...` where `ab` is the first two hex characters of the SHA-1 hash and `cdef...` is the rest. Pack files (compressed bundles of many objects) live in `.git/objects/pack/`. `GitRepository` handles both loose objects and pack file reading transparently. + +The HTTP smart protocol works in two round trips for a fetch: first a discovery request that returns the list of refs the remote knows about, then a pack-file negotiation that uploads a pack containing only the objects you don't already have. `GitRemote` implements this protocol using PHP's HTTP client, with no native dependencies. diff --git a/components/HTML/README.md b/components/HTML/README.md index b034be17d..a736e7918 100644 --- a/components/HTML/README.md +++ b/components/HTML/README.md @@ -1,260 +1,142 @@ # HTML -A full HTML5 parser and tag processor implemented in pure PHP, mirroring WordPress core's HTML API. It provides two levels of access: `WP_HTML_Tag_Processor` for fast, linear scanning and modification of HTML attributes, and `WP_HTML_Processor` for structure-aware parsing that understands nested elements, implicit tag closers, and the HTML5 insertion algorithm. No libxml2, no DOM extension, no external dependencies. +## Why this exists -## Installation +Modifying HTML in PHP usually means one of two things: string manipulation (fragile, breaks on any attribute ordering or whitespace variation) or loading the DOM extension (which requires libxml2, triggers errors on valid HTML5 that doesn't conform to XML rules, and mangles the document in the process). -``` -composer require wp-php-toolkit/html -``` +WordPress needed a third option: a parser that can safely scan and modify real-world HTML — including malformed markup — without any native extension, without loading the whole document into memory, and without altering content it wasn't asked to change. The result is `WP_HTML_Tag_Processor` and `WP_HTML_Processor`, both mirrored here from WordPress core for use outside WordPress. -## Quick Start +The key design insight is that most HTML processing tasks don't need a full DOM tree. You want to find a tag and change one of its attributes. You want to add a class to every ``. You don't need to understand the document structure for that — you just need to scan forward efficiently. `WP_HTML_Tag_Processor` handles that case. When you do need structure — "find the `` inside a `

    ` inside a `
    +
    + + + diff --git a/docs/httpserver/index.html b/docs/_legacy/httpserver/index.html similarity index 69% rename from docs/httpserver/index.html rename to docs/_legacy/httpserver/index.html index cb32ec9c5..08df63089 100644 --- a/docs/httpserver/index.html +++ b/docs/_legacy/httpserver/index.html @@ -5,10 +5,10 @@ HttpServer — PHP Toolkit - + - +
    @@ -50,10 +50,18 @@

    HttpServer

    A minimal blocking TCP HTTP server in pure PHP. For CLI tools and tests, not for production traffic.

    composer require wp-php-toolkit/http-server -

    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.

    +

    Sometimes a PHP tool needs a tiny local HTTP surface: a test fixture server, a webhook receiver during development, a CLI tool with a browser UI, or a demo endpoint for another component. Pulling in a production web framework would obscure the example and add dependencies the toolkit avoids.

    The HttpServer component is intentionally small: a blocking TCP server, incoming request objects, and response writers. It is useful for local tools and tests. It is not a replacement for nginx, Apache, php-fpm, RoadRunner, Swoole, or a production application server.

    +

    Use HttpServer when a PHP tool needs one local endpoint. A CLI command can open http://127.0.0.1:8765/callback for an OAuth flow, serve fixture JSON to HttpClient tests, or expose a tiny status page during an import.

    The server accepts a connection, parses one request, and gives your handler a response writer. Keep the process lifetime and shutdown rule in your command.

    +

    You will learn to:

    +
      +
    • Serve one response
    • +
    • Route a small local API
    • +
    • Buffer when headers depend on the body
    • +
    +

    Most snippets below run in the browser through WordPress Playground. Click Run on any example to execute it; edit the code and run again to see what changes. Static snippets show config or shell commands that need a real local environment.

    Hello world on port 8080

    Run on your machine: the Playground sandbox does not allow processes to bind listening TCP ports. Save this snippet locally and run php hello-server.php.

    - + + -

    Full API reference: HttpServer README.

    +

    See also

    +
    diff --git a/docs/_legacy/index.html b/docs/_legacy/index.html new file mode 100644 index 000000000..a79f1dff9 --- /dev/null +++ b/docs/_legacy/index.html @@ -0,0 +1,60 @@ + + + + + +PHP Toolkit — runnable docs + + + + +
    + PHP Toolkit + +
    +
    +

    PHP Toolkit

    +

    Eighteen standalone pure-PHP libraries for WordPress and general PHP, with no extension or Composer dependencies. Each guide starts with the story for that component, outlines the route through the page, names the main APIs, and then uses examples only where code clarifies the idea.

    + +

    Choose a Path

    + + +

    Components

    + + +

    How these examples work

    +

    Most PHP examples embed <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.

    +

    Examples that need a local listening port, a web server, or deployment-specific config are presented as static code blocks so the page does not imply they can run in the browser sandbox.

    +

    The toolkit bundle (docs/assets/php-toolkit.zip, ≈1.8 MB) ships with the docs, so no third-party CDN is involved.

    +
    + + + diff --git a/docs/markdown/index.html b/docs/_legacy/markdown/index.html similarity index 62% rename from docs/markdown/index.html rename to docs/_legacy/markdown/index.html index 4e0701121..a433368a0 100644 --- a/docs/markdown/index.html +++ b/docs/_legacy/markdown/index.html @@ -4,11 +4,11 @@ Markdown — PHP Toolkit - - + + - +
    @@ -48,9 +48,17 @@

    Markdown

    -

    Bidirectional converter between Markdown and WordPress block markup. Round-trips faithfully so you can keep Markdown files and a WP database in sync.

    +

    Bidirectional converter between Markdown and WordPress block markup. Useful for moving content between Markdown files and WordPress while preserving the structures both formats can express.

    composer require wp-php-toolkit/markdown -

    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.

    +

    Many publishing workflows start in Markdown: documentation sites, static-site generators, Git-backed editorial workflows, Obsidian vaults, and developer notes. WordPress stores editor content as block markup. Moving between those worlds by string replacement loses metadata and quickly breaks on lists, tables, code blocks, and frontmatter.

    The Markdown component provides a structured bridge. MarkdownConsumer turns Markdown plus frontmatter into block markup and metadata; MarkdownProducer turns supported block markup back into Markdown. The conversion is meant for practical content workflows, not byte-identical round-tripping of every custom block attribute.

    +

    Use Markdown for files that humans edit and block markup for content that WordPress stores. This component translates the supported middle ground: headings, paragraphs, lists, code blocks, links, images, and frontmatter-backed metadata.

    Keep unsupported syntax visible. A migration tool should tell you that a file contains an unsupported table instead of silently dropping it before publishing.

    +

    You will learn to:

    +
      +
    • Convert one document
    • +
    • Carry metadata beside content
    • +
    • Prepare a folder import
    • +
    +

    Most snippets below run in the browser through WordPress Playground. Click Run on any example to execute it; edit the code and run again to see what changes. Static snippets show config or shell commands that need a real local environment.

    Markdown to blocks

    Feed Markdown into MarkdownConsumer, get block markup back. The result is a BlocksWithMetadata object that holds both the rendered blocks and any frontmatter parsed from the document.

    @@ -63,6 +71,15 @@

    Markdown to blocks

    $result = ( new MarkdownConsumer( "# Hello\n\nWelcome to **WordPress**." ) )->consume(); echo $result->get_block_markup(); +

    Round-trip: blocks back to Markdown

    Pair MarkdownProducer with MarkdownConsumer to convert in either direction. Round-tripping is lossy for block attributes that have no Markdown representation (custom classes, alignment), so do not expect byte-perfect equality.

    @@ -80,6 +97,13 @@

    Round-trip: blocks back to Markdown< echo $markdown; +

    Reading YAML frontmatter as post meta

    Frontmatter keys come back as arrays so a single key can hold multiple values. Use get_meta_value() when you only want the first scalar.

    @@ -105,7 +129,13 @@

    Reading YAML frontmatter as post echo 'Title: ' . $consumer->get_meta_value( 'post_title' ) . "\n"; echo 'Status: ' . $consumer->get_meta_value( 'post_status' ) . "\n"; -print_r( $consumer->get_all_metadata() ); +$metadata = $consumer->get_all_metadata(); +echo 'Tags: ' . implode( ', ', $metadata['tags'][0] ) . "\n"; + +

    Migrating an Obsidian or Hugo folder of Markdown

    @@ -130,6 +160,21 @@

    Migrating an Obsidian echo substr( $consumer->get_block_markup(), 0, 120 ) . "...\n\n"; } +

    Counting blocks produced by a Markdown document

    After conversion, the block markup is plain WordPress block markup, so parse_blocks() works on it directly. The standard way to introspect what the converter emitted before saving to the database.

    @@ -158,14 +203,36 @@

    Counting blocks produce $blocks = ( new MarkdownConsumer( $md ) )->consume()->get_block_markup(); $counts = array(); -foreach ( parse_blocks( $blocks ) as $block ) { - if ( ! $block['blockName'] ) continue; - $counts[ $block['blockName'] ] = ( isset( $counts[ $block['blockName'] ] ) ? $counts[ $block['blockName'] ] : 0 ) + 1; +$queue = parse_blocks( $blocks ); + +while ( $queue ) { + $block = array_shift( $queue ); + if ( null !== $block['blockName'] ) { + $name = $block['blockName']; + $counts[ $name ] = isset( $counts[ $name ] ) ? $counts[ $name ] + 1 : 1; + } + foreach ( $block['innerBlocks'] as $inner_block ) { + $queue[] = $inner_block; + } } -print_r( $counts ); +foreach ( $counts as $name => $count ) { + echo "{$name}: {$count}\n"; +} + + -

    Full API reference: Markdown README.

    +

    See also

    +
    diff --git a/docs/merge/index.html b/docs/_legacy/merge/index.html similarity index 69% rename from docs/merge/index.html rename to docs/_legacy/merge/index.html index a0fc7a51d..f9b1b0553 100644 --- a/docs/merge/index.html +++ b/docs/_legacy/merge/index.html @@ -5,10 +5,10 @@ Merge — PHP Toolkit - + - +
    @@ -50,7 +50,15 @@

    Merge

    Three-way merge and diff. Pluggable differ + merger + optional validator.

    composer require wp-php-toolkit/merge -

    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.

    +

    Content synchronization needs more than "last write wins." A Markdown file changes in Git while the same post changes in WordPress. A generated config changes through both a CLI tool and a UI. In those cases you need a common ancestor, two edited versions, and a way to explain conflicts to a human.

    The Merge component provides the diff and three-way merge primitives used by those workflows. The default examples are line-oriented because that is the most familiar shape, but the strategy is intentionally pluggable: choose the differ, choose the merger, and optionally validate the merged result before accepting it.

    Use the merge result to auto-accept independent edits and to show structured conflicts when a person must decide.

    +

    A three-way merge needs the common base, your version, and their version. The base tells the merger whether two lines changed independently or collided.

    Start with line merges for Markdown, config files, and generated PHP. Move to a domain-specific differ only when lines hide the real unit of change.

    +

    You will learn to:

    +
      +
    • See the edit
    • +
    • Auto-merge independent lines
    • +
    • Surface conflicts
    • +
    +

    Most snippets below run in the browser through WordPress Playground. Click Run on any example to execute it; edit the code and run again to see what changes. Static snippets show config or shell commands that need a real local environment.

    Diff two strings line by line

    Feed two strings to LineDiffer and inspect the operations. Every get_changes() entry is a [op, text] pair.

    @@ -71,6 +79,14 @@

    Diff two strings line by line

    echo $labels[ $change[0] ] . ' ' . rtrim( $change[1] ) . "\n"; } +

    Render a unified patch

    format_as_git_patch() produces output that mirrors git diff, including hunk headers — handy for emails, CI annotations, or a "what changed?" panel.

    @@ -90,6 +106,17 @@

    Render a unified patch

    'b_source' => 'b/post.yml', ) ); +

    Three-way merge with no conflicts

    The classic case: each branch changes a different region. Pass the common ancestor plus both edits to MergeStrategy::merge() and read the merged result.

    @@ -113,6 +140,13 @@

    Three-way merge with no conflicts

    has_conflicts() ? "conflicts!\n" : "clean merge:\n"; echo $result->get_merged_content(); +

    Inspect and surface conflicts

    When both sides edit the same region, the merger produces a MergeConflict. The merged content carries Git-style markers, but the structured get_conflicts() output is what you want for a UI that lets the user pick a side.

    @@ -141,6 +175,21 @@

    Inspect and surface conflicts

    echo "\n--- merged content with markers ---\n"; echo $result->get_merged_content(); +

    Sync a Markdown folder against an edited DB copy

    A real-world scenario: posts live both in a Git-tracked Markdown folder and in WordPress, and someone edits each. Three-way-merge each post against its common ancestor.

    @@ -175,8 +224,39 @@

    Sync a Markdown folder echo $result->get_merged_content() . "\n"; } + -

    Full API reference: Merge README.

    +

    See also

    +
    diff --git a/docs/polyfill/index.html b/docs/_legacy/polyfill/index.html similarity index 78% rename from docs/polyfill/index.html rename to docs/_legacy/polyfill/index.html index 86847e88d..3711a0749 100644 --- a/docs/polyfill/index.html +++ b/docs/_legacy/polyfill/index.html @@ -5,10 +5,10 @@ Polyfill — PHP Toolkit - + - +
    @@ -50,9 +50,15 @@

    Polyfill

    PHP 8 string functions on PHP 7.2+, WordPress hook stubs, and translation/escaping passthroughs so toolkit code runs without WordPress.

    composer require wp-php-toolkit/polyfill -

    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.

    -

    Why this exists

    A lot of WordPress-adjacent code wants to call esc_html(), __(), or apply_filters() without booting WordPress. The polyfill component provides minimal but real implementations so that code runs unchanged outside WordPress, and stays out of the way when WordPress is loaded (every function uses function_exists() guards).

    +

    Load Polyfill when toolkit code runs outside WordPress but still calls WordPress-shaped helpers. Standalone tests can call esc_html(), add a filter, or use a translation stub without booting WordPress.

    The component defines only missing functions. If WordPress or the current PHP runtime already provides a function, the polyfill leaves it alone.

    +

    You will learn to:

    +
      +
    • Backfill missing PHP helpers
    • +
    • Keep familiar WordPress calls
    • +
    • Expose extension points
    • +
    +

    Most snippets below run in the browser through WordPress Playground. Click Run on any example to execute it; edit the code and run again to see what changes. Static snippets show config or shell commands that need a real local environment.

    PHP 8 string functions on PHP 7.2

    The polyfills define str_contains, str_starts_with, str_ends_with, and array_key_first only when missing.

    @@ -67,6 +73,12 @@

    PHP 8 string functions on PHP 7.2

    $first_key = array_key_first( array( 'alpha' => 1, 'beta' => 2 ) ); echo "first key: {$first_key}\n"; +

    Escaping and translation stubs

    Pass-through implementations let you write code that looks WordPressy and runs anywhere.

    @@ -80,6 +92,12 @@

    Escaping and translation stubs

    echo esc_attr( 'a "quoted" value' ) . "\n"; echo esc_url( 'https://example.com/?a=1&b=2' ) . "\n"; +

    A simple filter chain

    The hook system is a real implementation of the WordPress filter API: registered callbacks get applied in priority order, and each one transforms the running value.

    @@ -96,6 +114,9 @@

    A simple filter chain

    echo apply_filters( 'sanitize_title', ' My Post Title ' ) . "\n"; +

    Priority ordering and multi-arg passing

    Lower priority numbers run first. The fourth argument to add_filter controls how many context values get passed to the callback.

    @@ -119,6 +140,9 @@

    Priority ordering and multi-arg echo apply_filters( 'render_price', '19.99', 19.99, 'EUR' ) . "\n"; +

    Hook-based extension points in standalone libraries

    Use do_action and apply_filters as cheap extension points in your own code, without depending on WordPress.

    @@ -149,10 +173,18 @@

    Hook-based extensio $pipeline->process( array( 'email' => ' USER@EXAMPLE.COM ' ) ); $pipeline->process( array( 'email' => 'OTHER@example.com' ) ); -print_r( $log ); +echo implode( "\n", $log ) . "\n"; + + -

    Full API reference: Polyfill README.

    +

    See also

    +
    diff --git a/docs/xml/index.html b/docs/_legacy/xml/index.html similarity index 66% rename from docs/xml/index.html rename to docs/_legacy/xml/index.html index b83b43015..facff8a2b 100644 --- a/docs/xml/index.html +++ b/docs/_legacy/xml/index.html @@ -5,10 +5,10 @@ XML — PHP Toolkit - + - +
    @@ -50,9 +50,15 @@

    XML

    A streaming, namespace-aware XML processor in pure PHP. Read and modify huge feeds, WXR exports, ePub manifests, and Office Open XML parts without ever loading the document into memory and without depending on libxml2.

    composer require wp-php-toolkit/xml -

    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.

    -

    Why a streaming XML processor

    -

    SimpleXMLElement and DOMDocument both need libxml2 and both build a complete in-memory tree. XMLProcessor walks the document forward as a cursor, keeps modifications in a side buffer, and emits the full updated XML with get_updated_xml() only when you ask for it.

    Footgun #1: namespaces are addressed by URI, never by prefix. get_attribute( 'wp', 'status' ) always returns null; you want get_attribute( 'http://wordpress.org/export/1.2/', 'status' ).

    Footgun #2: in streaming mode next_tag() can return false because input ran out, not because the document ended. Check is_paused_at_incomplete_input() before assuming you're done.

    +

    SimpleXMLElement and DOMDocument both need libxml2 and both build a complete in-memory tree. XMLProcessor walks the document forward as a cursor, keeps modifications in a side buffer, and emits the full updated XML with get_updated_xml() only when you ask for it.

    This design came from WordPress-scale documents such as WXR exports. A migration may only need to rewrite wp:attachment_url values or bump a feed attribute, so the processor optimizes for targeted cursor edits instead of a full validating XML stack.

    Footgun #1: namespace-aware methods use the namespace name declared in xmlns, not the prefix written in the tag. In WXR, get_attribute( 'wp', 'status' ) looks for a namespace literally named wp; for the usual WXR declaration you want get_attribute( 'http://wordpress.org/export/1.2/', 'status' ).

    Footgun #2: in streaming mode next_tag() can return false because input ran out, not because the document ended. Check is_paused_at_incomplete_input() before assuming you're done.

    +

    XMLProcessor walks XML as a cursor. It reads the next tag, exposes attributes and text, records edits, and emits updated XML only when you call get_updated_xml().

    Query namespaces by URI, not by prefix. In WXR, look for http://wordpress.org/export/1.2/ even when the source file writes the prefix as wp:.

    +

    You will learn to:

    +
      +
    • Edit one attribute
    • +
    • Read namespaced exports
    • +
    • Process export-sized files
    • +
    +

    Most snippets below run in the browser through WordPress Playground. Click Run on any example to execute it; edit the code and run again to see what changes. Static snippets show config or shell commands that need a real local environment.

    Bump every price in a catalog

    Find each <book>, read its price, write a new one, emit the updated document.

    @@ -76,9 +82,12 @@

    Bump every price in a catalog

    echo $p->get_updated_xml(); +

    Read namespaced attributes from a WXR export

    -

    WordPress's WXR uses wp:, dc:, and content: prefixes. Always pass the URI, not the prefix; the processor handles whichever prefix the document actually uses.

    +

    WordPress's WXR commonly uses wp:, dc:, and content: prefixes bound to namespace names such as http://wordpress.org/export/1.2/. Pass that expanded namespace name, not the prefix; the processor handles whichever prefix the document actually uses.

    +

    Rewrite URLs across an entire WXR export

    -

    WXR holds tens of thousands of URLs in <link>, <guid>, and post content. Streaming the file lets you rewrite multi-hundred-megabyte exports without going OOM.

    +

    Large WXR exports can hold many URLs in <link>, <guid>, and post content. Streaming the file lets you rewrite large exports without loading the whole XML document into memory.

    +

    Parse OPML to extract feed URLs

    OPML is the format Feedly and many readers use to import/export feed lists. Flat, attribute-heavy XML — exactly what a tag processor handles best.

    @@ -169,8 +189,18 @@

    Parse OPML to extract feed URLs

    echo $p->get_attribute( '', 'text' ) . "\t" . $url . "\n"; } + -

    Full API reference: XML README.

    +

    See also

    +
    diff --git a/docs/zip/index.html b/docs/_legacy/zip/index.html similarity index 74% rename from docs/zip/index.html rename to docs/_legacy/zip/index.html index 2e2cf6259..9d0d1b1d3 100644 --- a/docs/zip/index.html +++ b/docs/_legacy/zip/index.html @@ -5,10 +5,10 @@ Zip — PHP Toolkit - + - +
    @@ -50,11 +50,17 @@

    Zip

    Read and write ZIP archives in pure PHP — no libzip, no ZipArchive. Streams entries one at a time, so you can build EPUBs, .docx files, and multi-gigabyte plugin bundles without buffering the archive in memory.

    composer require wp-php-toolkit/zip -

    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.

    -

    Why this exists

    -

    The PHP ecosystem has two ZIP options: the ZipArchive extension (often missing on shared hosts and stripped from WebAssembly builds) and shelling out to zip. Neither helps you stream a 4 GB plugin bundle to the browser, peek at an EPUB manifest without unpacking it, or build a .docx on a host without libzip.

    The Zip component reads and writes Stored and Deflate archives in pure PHP. The decoder is pull-based, so listing the central directory of a 2 GB ZIP costs roughly the size of the directory itself. The encoder accepts any ByteWriteStream as a sink and writes one entry at a time.

    +

    Common PHP ZIP workflows rely on the ZipArchive extension or shelling out to zip. Those are awkward in hosts without libzip, WebAssembly builds, and code paths that need to stream archive data through toolkit byte streams.

    The Zip component reads and writes Stored and Deflate archives in pure PHP. The decoder is pull-based, so listing the central directory of a 2 GB ZIP costs roughly the size of the directory itself. The encoder accepts any ByteWriteStream as a sink and writes one entry at a time.

    +

    Treat a ZIP as a small filesystem with a table of contents at the end. Read the central directory, open one entry stream, and copy that entry where it belongs.

    Use ZipFilesystem when your code wants get_contents() and ls(). Use ZipEncoder and ZipDecoder when the archive format matters, such as an EPUB that must store mimetype first and uncompressed.

    +

    You will learn to:

    +
      +
    • Open an archive as files
    • +
    • Write a format with rules
    • +
    • Move archives through streams
    • +
    +

    Most snippets below run in the browser through WordPress Playground. Click Run on any example to execute it; edit the code and run again to see what changes. Static snippets show config or shell commands that need a real local environment.

    Read a file out of a ZIP

    -

    ZipFilesystem implements the standard Filesystem interface, so once you wrap the byte reader you can call get_contents(), ls(), is_dir() just like local disk.

    +

    ZipFilesystem implements this toolkit's Filesystem interface, so once you wrap the byte reader you can call get_contents(), ls(), and is_dir() just like the other filesystem backends.

    Try this: after Run, add a second append_file() call before $enc->close() for a notes.md entry, then call print_r( $zip->ls( '/' ) ) at the end. The directory listing reflects the new entry without re-reading the file.

    +

    Build an EPUB from scratch

    -

    An EPUB is a ZIP with one strict rule: the mimetype entry must come first and must be Stored. Everything else can be Deflate.

    Gotcha: e-readers reject EPUBs whose mimetype entry has compression. Use COMPRESSION_NONE for that single entry.

    +

    An EPUB follows one strict ZIP rule: write the mimetype entry first and store it without compression. Deflate the rest of the archive normally.

    Gotcha: e-readers reject EPUBs whose mimetype entry has compression. Use COMPRESSION_NONE for that single entry.

    +

    Stream a large entry without buffering it

    Calling get_contents() on a 500 MB CSV inside a ZIP would eat 500 MB of RAM. Use open_read_stream() instead and inflate-as-you-go.

    Gotcha: only one entry stream open at a time. Drain or finish the previous stream before opening the next.

    @@ -176,6 +189,9 @@

    Stream a large entry without } printf( "Inflated %d bytes in 8 KB chunks, parsed %d rows.\n", $bytes, $rows ); +

    Repack: modify one file, copy the rest

    Updating one file in a ZIP without rewriting the others is impossible at the format level — the central directory points at byte offsets. The pragmatic answer is repack: stream the source archive into a new one, swapping the file you care about.

    @@ -214,11 +230,13 @@

    Repack: modify one file, copy the $dst_out = FileWriteStream::from_path( $dst_path, 'truncate' ); $dst_enc = new ZipEncoder( $dst_out ); -$walk = function ( $dir ) use ( &$walk, $source, $dst_enc ) { +$dirs = array( '/' ); +while ( $dirs ) { + $dir = array_shift( $dirs ); foreach ( $source->ls( $dir ) as $name ) { $path = rtrim( $dir, '/' ) . '/' . $name; if ( $source->is_dir( $path ) ) { - $walk( $path ); + $dirs[] = $path; continue; } $rel = ltrim( $path, '/' ); @@ -231,8 +249,7 @@

    Repack: modify one file, copy the 'body_reader' => new MemoryPipe( $body ), ) ) ); } -}; -$walk( '/' ); +} $dst_enc->close(); $dst_out->close_writing(); @@ -240,6 +257,10 @@

    Repack: modify one file, copy the echo "new config.json: " . $repacked->get_contents( 'config.json' ) . "\n"; echo "untouched: " . $repacked->get_contents( 'app/index.php' ) . "\n"; +

    Defend against zip-slip

    A malicious archive can name an entry ../../etc/passwd and trick a naive extractor into clobbering files outside the destination. ZipDecoder::sanitize_path() strips leading ../ segments and collapses internal /../ sequences before exposing the path.

    @@ -261,6 +282,13 @@

    Defend against zip-slip

    printf( "%-45s => %s\n", $name, ZipDecoder::sanitize_path( $name ) ); } +

    Pipe ZIP entries into an InMemoryFilesystem

    Real-world recipe: take an uploaded plugin ZIP, expand it into an InMemoryFilesystem so you can validate, edit, or scan it before it ever touches disk. Three components compose into something you couldn't build with ZipArchive alone.

    @@ -308,16 +336,39 @@

    Pipe ZIP entries into an In $mem->put_contents( '/app/VERSION', '1.0.0' ); echo "files now in memory:\n"; -$walk = function ( $dir ) use ( &$walk, $mem ) { +$dirs = array( '/' ); +$files = array(); +while ( $dirs ) { + $dir = array_shift( $dirs ); foreach ( $mem->ls( $dir ) as $name ) { $p = rtrim( $dir, '/' ) . '/' . $name; - $mem->is_dir( $p ) ? $walk( $p ) : print( " " . $p . "\n" ); + if ( $mem->is_dir( $p ) ) { + $dirs[] = $p; + continue; + } + $files[] = $p; } -}; -$walk( '/' ); +} +sort( $files ); +foreach ( $files as $path ) { + echo " " . $path . "\n"; +} + + -

    Full API reference: Zip README.

    +

    See also

    +