Title %1$d
Body link

diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fb0fd4e36..3a0b2211e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -46,20 +46,6 @@ jobs: -x "*/Tests/*" "*/tests/*" "*/.git/*" "*/.github/*" "*/node_modules/*" php bin/build-reference.php - - name: Preserve Native APIs Playground release history - env: - EXTENSION_PATH: wp_native_apis-wasm-extension - run: | - set -euo pipefail - pages_dir="$(mktemp -d)" - if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then - git clone --depth=1 --branch gh-pages "https://github.com/${GITHUB_REPOSITORY}.git" "$pages_dir" - if [ -d "$pages_dir/$EXTENSION_PATH" ]; then - rm -rf "docs/$EXTENSION_PATH" - cp -R "$pages_dir/$EXTENSION_PATH" "docs/$EXTENSION_PATH" - fi - fi - - uses: actions/upload-pages-artifact@v3 with: path: ./docs diff --git a/.github/workflows/native-apis-playground-extension.yml b/.github/workflows/native-apis-playground-extension.yml deleted file mode 100644 index d1df45a4e..000000000 --- a/.github/workflows/native-apis-playground-extension.yml +++ /dev/null @@ -1,761 +0,0 @@ -name: Native APIs Playground Extension - -on: - pull_request: - branches: - - trunk - paths: - - 'extensions/native-apis/**' - - 'bin/benchmark-native-apis.php' - - 'bin/summarize-native-api-benchmark.php' - - '.github/workflows/native-apis-playground-extension.yml' - push: - branches: - - trunk - paths: - - 'extensions/native-apis/**' - - 'bin/benchmark-native-apis.php' - - 'bin/summarize-native-api-benchmark.php' - - '.github/workflows/native-apis-playground-extension.yml' - workflow_dispatch: - inputs: - retention-days: - description: 'Actions artifact retention in days' - required: false - default: '30' - -# Disable permissions for all available scopes by default. -# Any needed permissions should be configured at the job level. -permissions: {} - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build PHP.wasm extension - runs-on: ubuntu-latest - timeout-minutes: 90 - permissions: - contents: read - env: - PHP_WASM_VERSIONS: '8.0,8.1,8.2,8.3,8.4,8.5' - - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '24' - - - name: Build wp_native_apis PHP.wasm extension - run: | - extensions/native-apis/build-playground-extension.sh build/wp_native_apis-wasm-extension - - - name: Normalize Playground extension manifest - env: - EXTENSION_VERSION: ${{ github.sha }} - run: | - node <<'NODE' - const fs = require('node:fs'); - const path = require('node:path'); - - const manifestPath = path.join('build', 'wp_native_apis-wasm-extension', 'manifest.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - manifest.version = process.env.EXTENSION_VERSION; - manifest.mode = 'php-extension'; - fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - NODE - - - name: Verify published manifest shape - run: | - node <<'NODE' - const fs = require('node:fs'); - const path = require('node:path'); - - const root = path.join('build', 'wp_native_apis-wasm-extension'); - const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8')); - if (manifest.name !== 'wp_native_apis') { - throw new Error(`Unexpected extension name: ${manifest.name}`); - } - if (manifest.mode !== 'php-extension') { - throw new Error(`Unexpected manifest mode: ${manifest.mode}`); - } - const expectedPhpVersions = process.env.PHP_WASM_VERSIONS.split(',').map((version) => version.trim()).filter(Boolean); - if (!Array.isArray(manifest.artifacts) || manifest.artifacts.length !== expectedPhpVersions.length) { - throw new Error(`Expected PHP.wasm artifacts for ${expectedPhpVersions.join(', ')}.`); - } - const seenPhpVersions = new Set(); - for (const artifact of manifest.artifacts) { - if (!artifact || typeof artifact.phpVersion !== 'string' || !expectedPhpVersions.includes(artifact.phpVersion) || !artifact.sourcePath) { - throw new Error(`Invalid artifact entry: ${JSON.stringify(artifact)}`); - } - if (seenPhpVersions.has(artifact.phpVersion)) { - throw new Error(`Duplicate artifact for PHP ${artifact.phpVersion}.`); - } - seenPhpVersions.add(artifact.phpVersion); - if ('file' in artifact || 'sha256' in artifact) { - throw new Error(`Manifest uses retired pre-PR-3580 fields: ${JSON.stringify(artifact)}`); - } - const artifactPath = path.join(root, artifact.sourcePath); - if (!fs.existsSync(artifactPath)) { - throw new Error(`Manifest references missing artifact: ${artifactPath}`); - } - } - for (const phpVersion of expectedPhpVersions) { - if (!seenPhpVersions.has(phpVersion)) { - throw new Error(`Missing artifact for PHP ${phpVersion}.`); - } - } - NODE - - - name: Write checksums - working-directory: build/wp_native_apis-wasm-extension - run: sha256sum *.so > SHA256SUMS - - - name: Upload Playground extension - uses: actions/upload-artifact@v4 - with: - name: wp-native-apis-playground-extension - path: build/wp_native_apis-wasm-extension/ - if-no-files-found: error - retention-days: ${{ github.event.inputs.retention-days || '30' }} - - benchmark-host-extension: - name: Benchmark host PHP extension - runs-on: ubuntu-latest - needs: build - timeout-minutes: 90 - permissions: - contents: read - - steps: - - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - extensions: mbstring, json - coverage: none - tools: composer:v2 - - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install native build dependencies - run: | - sudo apt-get update - sudo apt-get install -y clang libclang-dev - php-config --version - - - name: Install Composer dependencies - run: composer install --prefer-dist --no-progress --no-suggest - - - name: Build native extension - run: extensions/native-apis/build-extension.sh - - - name: Run native API benchmarks - run: | - mkdir -p build/native-api-benchmark - php -d extension=extensions/native-apis/target/release/libwp_native_apis.so \ - bin/benchmark-native-apis.php \ - --iterations=50 \ - --mode=both \ - --disable-native-defaults \ - --require-native > build/native-api-benchmark/benchmark.json - php bin/summarize-native-api-benchmark.php \ - build/native-api-benchmark/benchmark.json \ - build/native-api-benchmark/benchmark-summary.json - - - name: Upload benchmark results - uses: actions/upload-artifact@v4 - with: - name: wp-native-apis-host-benchmark - path: build/native-api-benchmark/ - if-no-files-found: error - retention-days: ${{ github.event.inputs.retention-days || '30' }} - - publish: - name: Publish static extension bundle to GitHub Pages - if: github.ref == 'refs/heads/trunk' && (github.event_name == 'workflow_dispatch' || github.event_name == 'push') - runs-on: ubuntu-latest - needs: - - build - - benchmark-host-extension - timeout-minutes: 30 - permissions: - contents: write - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - concurrency: - group: pages - cancel-in-progress: true - - 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 docs dependencies - run: composer install --no-dev --optimize-autoloader --no-progress - - - name: Build docs site - 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/*" - php bin/build-reference.php - - - name: Download packaged extension - uses: actions/download-artifact@v4 - with: - name: wp-native-apis-playground-extension - path: build/wp_native_apis-wasm-extension - - - name: Download host benchmark results - uses: actions/download-artifact@v4 - with: - name: wp-native-apis-host-benchmark - path: build/native-api-benchmark - - - name: Publish static extension bundle to gh-pages - env: - BUNDLE_DIR: build/wp_native_apis-wasm-extension - BENCHMARK_DIR: build/native-api-benchmark - DOCS_DIR: docs - EXTENSION_PATH: wp_native_apis-wasm-extension - GITHUB_TOKEN: ${{ github.token }} - SITE_BRANCH: gh-pages - VERSION_PATH: ${{ github.sha }} - run: | - set -euo pipefail - - pages_dir="$(mktemp -d)" - git init "$pages_dir" - git -C "$pages_dir" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" - auth_header="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 -w 0)" - git -C "$pages_dir" config http.https://github.com/.extraheader "AUTHORIZATION: basic ${auth_header}" - git -C "$pages_dir" config user.name "github-actions[bot]" - git -C "$pages_dir" config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - if git -C "$pages_dir" ls-remote --exit-code --heads origin "$SITE_BRANCH" >/dev/null 2>&1; then - git -C "$pages_dir" fetch origin "$SITE_BRANCH" - git -C "$pages_dir" checkout -B "$SITE_BRANCH" FETCH_HEAD - else - git -C "$pages_dir" checkout --orphan "$SITE_BRANCH" - fi - - mkdir -p "$pages_dir/$EXTENSION_PATH" - rm -rf "$pages_dir/$EXTENSION_PATH/$VERSION_PATH" "$pages_dir/$EXTENSION_PATH/latest" - mkdir -p "$pages_dir/$EXTENSION_PATH/$VERSION_PATH" "$pages_dir/$EXTENSION_PATH/latest" - cp -R "$BUNDLE_DIR"/. "$pages_dir/$EXTENSION_PATH/$VERSION_PATH/" - cp -R "$BUNDLE_DIR"/. "$pages_dir/$EXTENSION_PATH/latest/" - if [ -d "$BENCHMARK_DIR" ]; then - mkdir -p "$pages_dir/$EXTENSION_PATH/$VERSION_PATH/benchmarks" "$pages_dir/$EXTENSION_PATH/latest/benchmarks" - cp "$BENCHMARK_DIR"/benchmark.json "$pages_dir/$EXTENSION_PATH/$VERSION_PATH/benchmarks/benchmark.json" - cp "$BENCHMARK_DIR"/benchmark-summary.json "$pages_dir/$EXTENSION_PATH/$VERSION_PATH/benchmarks/benchmark-summary.json" - cp "$BENCHMARK_DIR"/benchmark.json "$pages_dir/$EXTENSION_PATH/latest/benchmarks/benchmark.json" - cp "$BENCHMARK_DIR"/benchmark-summary.json "$pages_dir/$EXTENSION_PATH/latest/benchmarks/benchmark-summary.json" - fi - touch "$pages_dir/.nojekyll" - - PAGES_DIR="$pages_dir" PUBLISHED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" node <<'NODE' - const childProcess = require('node:child_process'); - const fs = require('node:fs'); - const os = require('node:os'); - const path = require('node:path'); - - const pagesDir = process.env.PAGES_DIR; - const extensionPath = process.env.EXTENSION_PATH; - const versionPath = process.env.VERSION_PATH; - const repository = process.env.GITHUB_REPOSITORY; - const [owner, repo] = repository.split('/'); - const root = path.join(pagesDir, extensionPath); - const siteRootUrl = `https://${owner.toLowerCase()}.github.io/${repo}`; - const rootUrl = `https://${owner.toLowerCase()}.github.io/${repo}/${extensionPath}`; - const releasesPath = path.join(root, 'releases.json'); - const indexPath = path.join(root, 'index.html'); - - function readJson(filePath, fallback) { - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')); - } catch (error) { - return fallback; - } - } - - function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - function comparePhpVersions(left, right) { - const leftParts = String(left).split('.').map((part) => Number.parseInt(part, 10) || 0); - const rightParts = String(right).split('.').map((part) => Number.parseInt(part, 10) || 0); - return (leftParts[0] - rightParts[0]) || (leftParts[1] - rightParts[1]); - } - - function lastPublishedAt(relativeManifestPath) { - try { - return childProcess.execFileSync( - 'git', - ['-C', pagesDir, 'log', '-1', '--format=%cI', '--', relativeManifestPath], - { encoding: 'utf8' } - ).trim(); - } catch (error) { - return ''; - } - } - - const previousReleases = readJson(releasesPath, []); - const sanitizedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wp-native-apis-pages-')); - - function isRegularFile(filePath) { - try { - return fs.lstatSync(filePath).isFile(); - } catch (error) { - return false; - } - } - - function isReleaseDirectoryName(name) { - return name === 'latest' || /^[0-9a-f]{40}$/.test(name); - } - - function isArtifactSourcePath(sourcePath) { - return typeof sourcePath === 'string' && /^wp_native_apis-php[0-9.]+-jspi\.so$/.test(sourcePath); - } - - /* - * Treat the gh-pages branch as an append-only release history, not - * as a trusted source tree. Only expected release directories and - * files are copied into the branch update and Pages artifact. - */ - for (const entry of fs.readdirSync(root, { withFileTypes: true })) { - if (!entry.isDirectory() || !isReleaseDirectoryName(entry.name)) { - continue; - } - - const releaseDirectory = path.join(root, entry.name); - const manifestPath = path.join(releaseDirectory, 'manifest.json'); - const checksumPath = path.join(releaseDirectory, 'SHA256SUMS'); - if (!isRegularFile(manifestPath) || !isRegularFile(checksumPath)) { - continue; - } - - const manifest = readJson(manifestPath, null); - if (!manifest || manifest.name !== 'wp_native_apis' || manifest.mode !== 'php-extension') { - continue; - } - - const artifacts = Array.isArray(manifest.artifacts) - ? manifest.artifacts.filter((artifact) => { - if ( - !artifact || - typeof artifact.phpVersion !== 'string' || - !/^[0-9]+\.[0-9]+$/.test(artifact.phpVersion) || - !isArtifactSourcePath(artifact.sourcePath) - ) { - return false; - } - - return isRegularFile(path.join(releaseDirectory, artifact.sourcePath)); - }) - : []; - - if (artifacts.length === 0) { - continue; - } - - const sanitizedDirectory = path.join(sanitizedRoot, entry.name); - fs.mkdirSync(sanitizedDirectory, { recursive: true }); - fs.writeFileSync( - path.join(sanitizedDirectory, 'manifest.json'), - `${JSON.stringify({ ...manifest, artifacts }, null, 2)}\n` - ); - fs.copyFileSync(checksumPath, path.join(sanitizedDirectory, 'SHA256SUMS')); - - for (const artifact of artifacts) { - fs.copyFileSync( - path.join(releaseDirectory, artifact.sourcePath), - path.join(sanitizedDirectory, artifact.sourcePath) - ); - } - - const benchmarkDirectory = path.join(releaseDirectory, 'benchmarks'); - const benchmarkJson = path.join(benchmarkDirectory, 'benchmark.json'); - const benchmarkSummaryJson = path.join(benchmarkDirectory, 'benchmark-summary.json'); - if (isRegularFile(benchmarkJson) && isRegularFile(benchmarkSummaryJson)) { - const sanitizedBenchmarkDirectory = path.join(sanitizedDirectory, 'benchmarks'); - fs.mkdirSync(sanitizedBenchmarkDirectory, { recursive: true }); - fs.copyFileSync(benchmarkJson, path.join(sanitizedBenchmarkDirectory, 'benchmark.json')); - fs.copyFileSync(benchmarkSummaryJson, path.join(sanitizedBenchmarkDirectory, 'benchmark-summary.json')); - } - } - - fs.rmSync(root, { recursive: true, force: true }); - fs.mkdirSync(root, { recursive: true }); - for (const entry of fs.readdirSync(sanitizedRoot, { withFileTypes: true })) { - fs.cpSync(path.join(sanitizedRoot, entry.name), path.join(root, entry.name), { recursive: true }); - } - - const previousByVersion = new Map( - Array.isArray(previousReleases) - ? previousReleases.map((release) => [release.version, release]) - : [] - ); - - const releases = fs.readdirSync(root, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && /^[0-9a-f]{40}$/.test(entry.name)) - .map((entry) => { - const version = entry.name; - const existing = previousByVersion.get(version) || {}; - const relativeManifestPath = path.join(extensionPath, version, 'manifest.json'); - const manifestPath = path.join(root, version, 'manifest.json'); - const manifest = readJson(manifestPath, {}); - const manifestUrl = `${rootUrl}/${version}/manifest.json`; - const benchmarkPath = path.join(root, version, 'benchmarks', 'benchmark.json'); - const benchmarkSummaryPath = path.join(root, version, 'benchmarks', 'benchmark-summary.json'); - const benchmarkSummary = readJson(benchmarkSummaryPath, null); - const blueprintUrl = `https://raw.githubusercontent.com/${repository}/${version}/extensions/native-apis/playground/blueprint.json`; - const playgroundUrl = (phpVersion) => `https://playground.wordpress.net/?php=${encodeURIComponent(phpVersion)}&php-extension=${encodeURIComponent(manifestUrl)}&blueprint-url=${encodeURIComponent(blueprintUrl)}`; - const artifacts = Array.isArray(manifest.artifacts) - ? manifest.artifacts.map((artifact) => ({ - phpVersion: artifact.phpVersion, - sourcePath: artifact.sourcePath, - url: `${rootUrl}/${version}/${artifact.sourcePath}`, - testInPlayground: playgroundUrl(artifact.phpVersion), - })).sort((left, right) => comparePhpVersions(left.phpVersion, right.phpVersion)) - : []; - const preferredArtifact = artifacts[artifacts.length - 1] || null; - - return { - version, - name: manifest.name || 'wp_native_apis', - phpVersions: artifacts.map((artifact) => artifact.phpVersion).filter(Boolean), - artifacts, - publishedAt: version === versionPath - ? process.env.PUBLISHED_AT - : existing.publishedAt || lastPublishedAt(relativeManifestPath), - manifest: manifestUrl, - checksums: `${rootUrl}/${version}/SHA256SUMS`, - commit: `https://github.com/${repository}/commit/${version}`, - workflowRun: version === versionPath - ? `https://github.com/${repository}/actions/runs/${process.env.GITHUB_RUN_ID}` - : existing.workflowRun || '', - benchmark: isRegularFile(benchmarkPath) - ? `${rootUrl}/${version}/benchmarks/benchmark.json` - : existing.benchmark || '', - benchmarkSummaryUrl: isRegularFile(benchmarkSummaryPath) - ? `${rootUrl}/${version}/benchmarks/benchmark-summary.json` - : existing.benchmarkSummaryUrl || '', - benchmarkSummary: benchmarkSummary && Array.isArray(benchmarkSummary.top) - ? benchmarkSummary.top - : existing.benchmarkSummary || [], - testInPlayground: preferredArtifact ? preferredArtifact.testInPlayground : '', - }; - }) - .sort((a, b) => { - if (a.publishedAt && b.publishedAt) { - return b.publishedAt.localeCompare(a.publishedAt); - } - return b.version.localeCompare(a.version); - }); - - fs.writeFileSync(releasesPath, `${JSON.stringify(releases, null, 2)}\n`); - - function formatSeconds(value) { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return 'n/a'; - } - if (value < 0.001) { - return `${(value * 1000000).toFixed(0)} µs`; - } - if (value < 1) { - return `${(value * 1000).toFixed(1)} ms`; - } - return `${value.toFixed(2)} s`; - } - - function formatDate(value) { - if (!value) { - return 'unknown'; - } - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return String(value).slice(0, 10); - } - return parsed.toISOString().slice(0, 10); - } - - function renderBenchmarkSummary(release) { - const items = Array.isArray(release.benchmarkSummary) - ? release.benchmarkSummary.slice(0, 3) - : []; - if (!items.length) { - return 'No benchmark summary'; - } - - return `
- Published PHP.wasm builds of the experimental wp_native_apis extension for WordPress Playground.
- Use this page to test the latest build, pin an immutable manifest, or inspect release artifacts.
-
| Released on | -Playground PHP versions | -CI speed snapshot | -Links | -|
|---|---|---|---|---|
| No releases published yet. | ||||
Want to build it today? Follow the native PHP compilation guide.
-Body link

Body link


Body
' ); - $this->assertTrue( $processor->is_native_processor_active() ); $this->assertTrue( $processor->next_tag( 'H1' ) ); $this->assertSame( 'Title', $processor->get_inner_html() ); - $this->assertTrue( $processor->is_native_processor_active() ); + $this->assertSame( 'H1', $processor->get_tag() ); $this->assertTrue( $processor->next_tag( 'P' ) ); $this->assertSame( 'P', $processor->get_tag() ); } - public function test_import_h1_removal_keeps_native_progressive_upgrade() { - if ( ! class_exists( 'WP_HTML_Native_Processor', false ) ) { - $this->markTestSkipped( 'The native HTML processor is not loaded.' ); - } - + public function test_import_h1_removal_preserves_remaining_markup() { $result = ImportUtils::remove_first_h1_block_from_block_markup( - 'Body
' + 'Body
' ); $this->assertSame( array( - 'h1_content' => 'Native Title', + 'h1_content' => 'Title', 'remaining_html' => 'Body
', ), $result diff --git a/components/DataLiberation/Tests/URLInTextProcessorTest.php b/components/DataLiberation/Tests/URLInTextProcessorTest.php index 62d3e2369..a3a870c55 100644 --- a/components/DataLiberation/Tests/URLInTextProcessorTest.php +++ b/components/DataLiberation/Tests/URLInTextProcessorTest.php @@ -5,28 +5,6 @@ class URLInTextProcessorTest extends TestCase { - public function test_direct_native_processor_finds_url_candidates_when_loaded() { - $native_class = 'WordPress\\DataLiberation\\URL\\NativeURLInTextProcessor'; - if ( ! class_exists( $native_class, false ) ) { - $this->markTestSkipped( 'Native URL-in-text processor is not loaded.' ); - } - - $p = new $native_class( 'Visit https://wordpress.org/plugins, then example.com/docs.' ); - $this->assertTrue( $p->next_url() ); - $this->assertSame( 'https://wordpress.org/plugins', $p->get_raw_url() ); - $this->assertSame( 6, $p->get_url_starts_at() ); - $this->assertTrue( $p->had_protocol() ); - - $this->assertTrue( $p->next_url() ); - $this->assertSame( 'example.com/docs', $p->get_raw_url() ); - $this->assertFalse( $p->had_protocol() ); - $this->assertTrue( $p->set_raw_url( 'example.org/handbook' ) ); - $this->assertSame( - 'Visit https://wordpress.org/plugins, then example.org/handbook.', - $p->get_updated_text() - ); - } - /** * @dataProvider provider_test_finds_next_url_when_base_url_is_used */ diff --git a/components/DataLiberation/URL/class-nativeurlintextprocessorwrapper.php b/components/DataLiberation/URL/class-nativeurlintextprocessorwrapper.php deleted file mode 100644 index a27c4f120..000000000 --- a/components/DataLiberation/URL/class-nativeurlintextprocessorwrapper.php +++ /dev/null @@ -1,241 +0,0 @@ -text = $text; - $this->base_url = $base_url; - $this->base_protocol = $base_url ? parse_url( $base_url, PHP_URL_SCHEME ) : null; - - if ( preg_match( '/[^\x00-\x7F]/', $text ) ) { - $this->fallback_processor = new PHPURLInTextProcessor( $text, $base_url ); - return; - } - - $native_class = 'WordPress\\DataLiberation\\URL\\NativeURLInTextProcessor'; - $this->native_processor = new $native_class( $text, $base_url ); - } - - public function next_url() { - if ( null !== $this->fallback_processor ) { - return $this->fallback_processor->next_url(); - } - - while ( $this->native_processor->next_url() ) { - $this->matched_url = $this->native_processor->get_raw_url(); - $this->parsed_url = null; - $this->url_starts_at = null; - $this->url_length = null; - $this->did_prepend_protocol = false; - - if ( false === $this->matched_url || '' === $this->matched_url || '::' === $this->matched_url ) { - continue; - } - - $url_starts_at = strpos( $this->text, $this->matched_url, $this->bytes_already_parsed ); - if ( false === $url_starts_at ) { - continue; - } - $this->bytes_already_parsed = $url_starts_at + strlen( $this->matched_url ); - $this->url_starts_at = $url_starts_at; - $this->url_length = strlen( $this->matched_url ); - - $had_protocol = method_exists( $this->native_processor, 'had_protocol' ) - ? $this->native_processor->had_protocol() - : WPURL::has_http_https_protocol( $this->matched_url ); - - $cache_key = $this->base_url . "\0" . ( $had_protocol ? '1' : '0' ) . "\0" . $this->matched_url; - $cached = self::candidate_cache()->get( $cache_key ); - if ( null !== $cached ) { - if ( false === $cached ) { - continue; - } - $this->parsed_url = $cached['parsed_url']; - $this->did_prepend_protocol = $cached['did_prepend_protocol']; - - return true; - } - - $parsed_url = $this->parse_and_validate_candidate( $had_protocol ); - if ( false === $parsed_url ) { - self::candidate_cache()->set( $cache_key, false ); - continue; - } - - self::candidate_cache()->set( - $cache_key, - array( - 'parsed_url' => $parsed_url, - 'did_prepend_protocol' => $this->did_prepend_protocol, - ) - ); - - $this->parsed_url = $parsed_url; - - return true; - } - - return false; - } - - public function get_raw_url() { - if ( null !== $this->fallback_processor ) { - return $this->fallback_processor->get_raw_url(); - } - - return $this->matched_url ?? false; - } - - public function get_parsed_url() { - if ( null !== $this->fallback_processor ) { - return $this->fallback_processor->get_parsed_url(); - } - - return $this->parsed_url ?? false; - } - - public function set_raw_url( $new_url ) { - if ( null !== $this->fallback_processor ) { - return $this->fallback_processor->set_raw_url( $new_url ); - } - - if ( null === $this->matched_url ) { - return false; - } - - if ( $this->did_prepend_protocol ) { - $new_url = substr( $new_url, strpos( $new_url, '://' ) + 3 ); - } - - $this->matched_url = $new_url; - $this->lexical_updates[ $this->url_starts_at ] = new WP_HTML_Text_Replacement( - $this->url_starts_at, - $this->url_length, - $new_url - ); - - return true; - } - - public function get_updated_text() { - if ( null !== $this->fallback_processor ) { - return $this->fallback_processor->get_updated_text(); - } - - $this->apply_lexical_updates(); - - return $this->text; - } - - private function parse_and_validate_candidate( $had_protocol ) { - $preprocessed_url = $this->matched_url; - if ( $this->base_url && $this->base_protocol && ! $had_protocol ) { - $preprocessed_url = WPURL::ensure_protocol( $preprocessed_url, $this->base_protocol ); - $this->did_prepend_protocol = true; - } - - $parsed_url = WPURL::parse( $preprocessed_url, $this->base_url ); - if ( false === $parsed_url ) { - return false; - } - - if ( $parsed_url->protocol && ! in_array( $parsed_url->protocol, array( 'http:', 'https:' ), true ) ) { - return false; - } - - if ( $parsed_url->username || $parsed_url->password ) { - return false; - } - - if ( ! $had_protocol ) { - $last_dot_position = strrpos( $parsed_url->hostname, '.' ); - if ( false === $last_dot_position ) { - return false; - } - - $tld = substr( $parsed_url->hostname, $last_dot_position + 1 ); - if ( ! WPURL::is_known_public_domain( $tld ) ) { - return false; - } - } - - return $parsed_url; - } - - private function apply_lexical_updates() { - if ( ! count( $this->lexical_updates ) ) { - return; - } - - ksort( $this->lexical_updates ); - - $bytes_already_copied = 0; - $output_buffer = ''; - foreach ( $this->lexical_updates as $diff ) { - $shift = strlen( $diff->text ) - $diff->length; - if ( $diff->start < $this->bytes_already_parsed ) { - $this->bytes_already_parsed += $shift; - } - - $output_buffer .= substr( $this->text, $bytes_already_copied, $diff->start - $bytes_already_copied ); - if ( $diff->start === $this->url_starts_at ) { - $this->url_starts_at = strlen( $output_buffer ); - $this->url_length = strlen( $diff->text ); - } - $output_buffer .= $diff->text; - $bytes_already_copied = $diff->start + $diff->length; - } - - $this->text = $output_buffer . substr( $this->text, $bytes_already_copied ); - $this->lexical_updates = array(); - } - - private static function candidate_cache() { - if ( null === self::$candidate_cache ) { - self::$candidate_cache = new URLRewriteCache( self::CANDIDATE_CACHE_MAX ); - } - - return self::$candidate_cache; - } -} diff --git a/components/DataLiberation/URL/class-urlintextprocessor.php b/components/DataLiberation/URL/class-urlintextprocessor.php index c5d64123a..39bfc3521 100644 --- a/components/DataLiberation/URL/class-urlintextprocessor.php +++ b/components/DataLiberation/URL/class-urlintextprocessor.php @@ -1,31 +1,7 @@ TitleBody
' ); - * $p->next_tag( 'H1' ); - * $inner_html = $p->get_inner_html(); // "Title". - * - * $p->skip_to_closer(); - * $after_h1 = substr( $html, $p->get_string_index_after_current_token() ); - * - * Those offsets are an implementation detail of the PHP parser; native HTML - * bookmarks are opaque and do not expose byte spans. To keep native processing - * available for normal scanning, these helpers replay the current cursor - * position on a PHP-backed mirror and read the offsets from that mirror. - */ class DataLiberationHTMLProcessor extends WP_HTML_Processor { - /** - * Number of public tokens consumed from this processor. - * - * This is enough to replay the cursor onto a PHP-backed mirror for the - * Data Liberation helpers that need PHP bookmark spans. - * - * @var int - */ - private $tokens_parsed_for_php_replay = 0; - - /** - * Token counts captured for public bookmarks. - * - * Native bookmarks are intentionally opaque. This map lets a later `seek()` - * restore the replay counter when the bookmark was created through this - * class, so the PHP mirror can land on the same token. - * - * @var arrayBody
' ); - * $native->next_tag( 'H1' ); - * - * // The mirror runs the PHP parser through the same first token, so its - * // bookmark spans point to the original `libxml2, DOMDocument, or regex hacks — and rewrite attributes in a single linear pass.
-
-When the native API extension is loaded, tag and fragment processors can use
-native delegates by default while preserving PHP fallback behavior. Define
-WP_NATIVE_APIS_DISABLE_DEFAULTS before loading the component to
-force the pure PHP fallback. Full-document parsing through
-WP_HTML_Processor::create_full_parser() remains PHP-backed for
-now. Fragment processors, including covered table, list, description-list,
-select/option/optgroup, omitted-paragraph, and ruby tree-builder cases, can use
-native delegates when the extension is loaded.
+A pure-PHP HTML parser and tag rewriter mirroring WordPress core's HTML API. Handle browser-style HTML fragments for supported markup — without libxml2, DOMDocument, or regex hacks — and rewrite attributes in a single linear pass.
## Why this exists
@@ -33,6 +24,8 @@ native delegates when the extension is loaded.
The component gives you two processors. WP_HTML_Tag_Processor is a forward-only cursor over tags and tokens — useful for attribute rewriting at scale. WP_HTML_Processor layers HTML5 tree construction on top so you can query by ancestry (breadcrumbs), serialize the parsed document, and trust that <p>one<p>two parses as two paragraphs the way a browser sees it.
Scope: WP_HTML_Processor intentionally supports WordPress core's current subset of HTML5. It aborts on markup it cannot safely model, including table-internal content, foreign content such as SVG/MathML, and content outside the supported body parsing modes. Use get_unsupported_exception() when you need to explain why processing stopped.
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.
Text
' ); - $this->assertSame( 'WP_HTML_Native_Tag_Processor', get_class( $this->get_native_delegate( $tag_processor ) ) ); - $this->assertTrue( $tag_processor->next_tag() ); - $this->assertSame( 'P', $tag_processor->get_tag() ); - $this->assertSame( '1', $tag_processor->get_attribute( 'data-id' ) ); - $this->assertTrue( $tag_processor->remove_attribute( 'data-id' ) ); - $this->assertNull( $tag_processor->get_attribute( 'data-id' ) ); - $this->assertSame( 'Text
', $tag_processor->get_updated_html() ); - - $tag_processor = new WP_HTML_Tag_Processor( 'One
' ); - $this->assertSame( 'WP_HTML_Native_Tag_Processor', get_class( $this->get_native_delegate( $tag_processor ) ) ); - $this->assertTrue( $tag_processor->next_token() ); - $this->assertTrue( $tag_processor->next_token() ); - $this->assertSame( '#text', $tag_processor->get_token_type() ); - $this->assertTrue( $tag_processor->set_modifiable_text( 'Two & Three' ) ); - $this->assertSame( 'Two & Three
', $tag_processor->get_updated_html() ); - - $tag_processor = new WP_HTML_Tag_Processor( " \tMore" ); - $this->assertSame( 'WP_HTML_Native_Tag_Processor', get_class( $this->get_native_delegate( $tag_processor ) ) ); - $this->assertTrue( $tag_processor->next_token() ); - $this->assertTrue( $tag_processor->subdivide_text_appropriately() ); - $this->assertSame( " \t", $tag_processor->get_modifiable_text() ); - $this->assertTrue( $tag_processor->next_token() ); - $this->assertSame( 'More', $tag_processor->get_modifiable_text() ); - - $tag_processor = new WP_HTML_Tag_Processor( 'Text
' ); - $this->assertSame( 'WP_HTML_Native_Processor', get_class( $this->get_native_delegate( $html_processor ) ) ); - $this->assertTrue( $html_processor->next_token() ); - $this->assertSame( 'P', $html_processor->get_token_name() ); - $this->assertSame( array( 'HTML', 'BODY', 'P' ), $html_processor->get_breadcrumbs() ); - - $list_processor = WP_HTML_Processor::create_fragment( 'one
| A | B |
Text
' ); - $this->assertSame( 'WP_HTML_Native_Tag_Processor', get_class( $this->get_native_delegate( $tag_processor ) ) ); - $this->assertTrue( $tag_processor->next_tag( 'p' ) ); - $this->assertTrue( $tag_processor->set_attribute( 'data-id', '7' ) ); - $this->assertSame( '7', $tag_processor->get_attribute( 'data-id' ) ); - $this->assertTrue( $tag_processor->add_class( 'b' ) ); - $this->assertSame( 'a b', $tag_processor->get_attribute( 'class' ) ); - $this->assertSame( 'Text
', $tag_processor->get_updated_html() ); - - $tag_processor = new WP_HTML_Tag_Processor( '
', $tag_processor->get_updated_html() );
-
- $tag_processor = new WP_HTML_Tag_Processor( 'Text
' ); - $this->assertTrue( $tag_processor->next_tag( 'p' ) ); - $this->assertTrue( $tag_processor->get_attribute( 'hidden' ) ); - $this->assertTrue( $tag_processor->set_attribute( 'hidden', true ) ); - $this->assertTrue( $tag_processor->get_attribute( 'hidden' ) ); - $this->assertSame( 'Text
', $tag_processor->get_updated_html() ); - - $tag_processor = new WP_HTML_Tag_Processor( 'Text
' ); - $this->assertTrue( $tag_processor->next_tag( 'p' ) ); - $this->assertTrue( $tag_processor->remove_class( 'a' ) ); - $this->assertSame( 'b', $tag_processor->get_attribute( 'class' ) ); - $this->assertSame( 'Text
', $tag_processor->get_updated_html() ); - - $tag_processor = new WP_HTML_Tag_Processor( 'Text
' ); - $this->assertTrue( $tag_processor->next_tag( 'p' ) ); - $this->assertTrue( $tag_processor->set_attribute( 'data-id', '7' ) ); - $this->assertFalse( $tag_processor->remove_attribute( 'data-id' ) ); - $this->assertNull( $tag_processor->get_attribute( 'data-id' ) ); - $this->assertSame( 'Text
', $tag_processor->get_updated_html() ); - - $tag_processor = new WP_HTML_Tag_Processor( 'Text
' ); - $this->assertTrue( $tag_processor->next_tag( 'p' ) ); - $this->assertTrue( $tag_processor->add_class( 'a' ) ); - $this->assertTrue( $tag_processor->remove_class( 'a' ) ); - $this->assertNull( $tag_processor->get_attribute( 'class' ) ); - $this->assertSame( 'Text
', $tag_processor->get_updated_html() ); - - $tag_processor = new WP_HTML_Tag_Processor( 'Text
' ); - $this->assertTrue( $tag_processor->next_tag( 'p' ) ); - $this->assertTrue( $tag_processor->remove_class( 'a' ) ); - $this->assertTrue( $tag_processor->add_class( 'a' ) ); - $this->assertSame( 'a b', $tag_processor->get_attribute( 'class' ) ); - $this->assertSame( 'Text
', $tag_processor->get_updated_html() ); - - $tag_processor = new WP_HTML_Tag_Processor( 'Text
' ); - $this->assertTrue( $tag_processor->next_tag( 'p' ) ); - $this->assertTrue( $tag_processor->remove_class( 'a' ) ); - $this->assertTrue( $tag_processor->add_class( 'b' ) ); - $this->assertSame( 'b', $tag_processor->get_attribute( 'class' ) ); - $this->assertSame( 'Text
', $tag_processor->get_updated_html() ); - - $html_processor = WP_HTML_Processor::create_fragment( 'Text
Text
Text
Text
Text
Text
More
More
One
Two
Text
' - ); - - $this->assertTrue( $processor->next_tag(), 'Expected the paragraph tag with unusual attributes.' ); - $this->assertSame( array( '.x', '@x', 'data-x' ), $processor->get_attribute_names_with_prefix( '' ) ); - $this->assertSame( 'dot', $processor->get_attribute( '.x' ) ); - $this->assertSame( 'at', $processor->get_attribute( '@x' ) ); - $this->assertNull( $processor->get_attribute( 'x' ) ); - - $processor = $this->create_tag_processor( - $implementation, - 'Text
' - ); - - $this->assertTrue( $processor->next_tag(), 'Expected the paragraph tag with equals-start attributes.' ); - $this->assertSame( array( '=b', 'a', '=c', 'data-x' ), $processor->get_attribute_names_with_prefix( '' ) ); - $this->assertTrue( $processor->get_attribute( '=b' ) ); - $this->assertTrue( $processor->get_attribute( '=c' ) ); - $this->assertNull( $processor->get_attribute( 'b' ) ); - $this->assertSame( array( '=b', '=c' ), $processor->get_attribute_names_with_prefix( '=' ) ); - $this->assertSame( 2, $processor->count_attribute_names_with_prefix( '=' ) ); - - $processor = $this->create_tag_processor( - $implementation, - '' - ); - - $this->assertTrue( $this->next_tag_visiting_closers( $processor ), 'Expected the opening tag.' ); - $this->assertSame( array( 'data-id' ), $processor->get_attribute_names_with_prefix( 'data-' ) ); - $this->assertSame( 1, $processor->count_attribute_names_with_prefix( 'data-' ) ); - $this->assertTrue( $this->next_tag_visiting_closers( $processor ), 'Expected the closing tag.' ); - $this->assertTrue( $processor->is_tag_closer() ); - $this->assertNull( $processor->get_attribute_names_with_prefix( 'data-' ) ); - $this->assertNull( $processor->count_attribute_names_with_prefix( 'data-' ) ); - } - - /** - * Verifies attribute removal works for the shared sanitization workflow. - * - * @dataProvider data_tag_processor_implementations - * - * @param string $implementation Implementation identifier. - */ - public function test_tag_processor_removes_attributes( $implementation ) { - $this->skip_if_native_is_unavailable( $implementation, 'WP_HTML_Native_Tag_Processor' ); - - $processor = $this->create_tag_processor( - $implementation, - 'Text
' - ); - - $this->assertTrue( $processor->next_tag(), 'Expected the paragraph tag with boolean attributes.' ); - $this->assertTrue( $processor->remove_attribute( 'disabled' ) ); - $this->assertSame( 'Text
', $processor->get_updated_html() ); - - $processor = $this->create_tag_processor( - $implementation, - 'Text
' - ); - - $this->assertTrue( $processor->next_tag(), 'Expected the paragraph tag with boolean attributes.' ); - $this->assertSame( 3, $processor->remove_attributes_with_prefix( 'd' ) ); - $this->assertSame( 'Text
', $processor->get_updated_html() ); - } - - /** - * Verifies document-level prefix removals match the per-tag workflow. - * - * @dataProvider data_tag_processor_implementations - * - * @param string $implementation Implementation identifier. - */ - public function test_tag_processor_removes_prefixed_attributes_from_document( $implementation ) { - $this->skip_if_native_is_unavailable( $implementation, 'WP_HTML_Native_Tag_Processor' ); - - $processor = $this->create_tag_processor( - $implementation, - 'One
Text
Text
Bool

' => ' html
Text
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected the comment token.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertSame( 'note', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_HTML_COMMENT', $processor->get_comment_type() ); - $this->assertSame( 'note', $processor->get_full_comment_text() ); - - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to skip the comment and find the paragraph tag.' ); - $this->assertSame( 'P', $processor->get_tag() ); - $this->assertNull( $processor->get_comment_type() ); - $this->assertNull( $processor->get_full_comment_text() ); - - $processor = $this->create_tag_processor( - $implementation, - 'Text
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected the comment token with a --!> close.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( 'note', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_HTML_COMMENT', $processor->get_comment_type() ); - $this->assertSame( 'note', $processor->get_full_comment_text() ); - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to find the paragraph after the --!> comment.' ); - $this->assertSame( 'P', $processor->get_tag() ); - - $processor = $this->create_tag_processor( - $implementation, - 'Text
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected the presumptuous tag token.' ); - $this->assertSame( '#presumptuous-tag', $processor->get_token_type() ); - $this->assertSame( '#presumptuous-tag', $processor->get_token_name() ); - $this->assertSame( '', $processor->get_modifiable_text() ); - $this->assertNull( $processor->get_full_comment_text() ); - - foreach ( array( '1', '%bad', '/', ' p', '_x', ':x' ) as $expected_text ) { - $this->assertTrue( $processor->next_token(), 'Expected a funky closing comment token.' ); - $this->assertSame( '#funky-comment', $processor->get_token_type() ); - $this->assertSame( '#funky-comment', $processor->get_token_name() ); - $this->assertSame( $expected_text, $processor->get_modifiable_text() ); - $this->assertSame( $expected_text, $processor->get_full_comment_text() ); - } - - $this->assertTrue( $processor->next_token(), 'Expected the abruptly closed HTML comment.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertSame( '', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_ABRUPTLY_CLOSED_COMMENT', $processor->get_comment_type() ); - $this->assertSame( '', $processor->get_full_comment_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected the dash-abruptly closed HTML comment.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertSame( '', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_ABRUPTLY_CLOSED_COMMENT', $processor->get_comment_type() ); - $this->assertSame( '', $processor->get_full_comment_text() ); - - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to skip invalid tokens and find the paragraph tag.' ); - $this->assertSame( 'P', $processor->get_tag() ); - } - - /** - * Verifies invalid opening tags remain text tokens like PHP userland. - * - * @dataProvider data_tag_processor_implementations - * - * @param string $implementation Implementation identifier. - */ - public function test_tag_processor_reads_invalid_opening_tag_text_tokens( $implementation ) { - $this->skip_if_native_is_unavailable( $implementation, 'WP_HTML_Native_Tag_Processor' ); - - $processor = $this->create_tag_processor( - $implementation, - 'before & <1><%bad><_x><:x><.x><-x>< p>Text
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected an invalid opening-tag text token.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( '#text', $processor->get_token_name() ); - $this->assertSame( 'before & <1><%bad><_x><:x><.x><-x>< p>', $processor->get_modifiable_text() ); - $this->assertNull( $processor->get_comment_type() ); - $this->assertNull( $processor->get_full_comment_text() ); - - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to skip invalid text and find the paragraph tag.' ); - $this->assertSame( 'P', $processor->get_tag() ); - - $processor = $this->create_tag_processor( $implementation, '<Text
<' ); - - $this->assertTrue( $processor->next_token(), 'Expected adjacent invalid opening text.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( '<', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected valid paragraph after adjacent invalid opening text.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'P', $processor->get_tag() ); - - $this->assertTrue( $processor->next_token(), 'Expected paragraph text.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( 'Text', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected paragraph closer.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'P', $processor->get_tag() ); - $this->assertTrue( $processor->is_tag_closer() ); - - $this->assertFalse( $processor->next_token(), 'Expected a trailing lone < not to expose a token.' ); - - $processor = $this->create_tag_processor( $implementation, 'xText
' ); - - $this->assertTrue( $processor->next_token(), 'Expected consecutive invalid openings to coalesce.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( '<< <', $processor->get_modifiable_text() ); - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to find the valid tag after consecutive invalid openings.' ); - $this->assertSame( 'P', $processor->get_tag() ); - } - - /** - * Verifies raw-text contents are attached to the opening tag like PHP userland. - * - * @dataProvider data_tag_processor_implementations - * - * @param string $implementation Implementation identifier. - */ - public function test_tag_processor_reads_raw_text_element_contents( $implementation ) { - $this->skip_if_native_is_unavailable( $implementation, 'WP_HTML_Native_Tag_Processor' ); - - $processor = $this->create_tag_processor( - $implementation, - 'I
x
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected the script token.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'SCRIPT', $processor->get_token_name() ); - $this->assertSame( 'if (a < b) { c(); }', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected the iframe token after the script contents.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'IFRAME', $processor->get_token_name() ); - $this->assertSame( 'A&C', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected the noembed token after the iframe contents.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'NOEMBED', $processor->get_token_name() ); - $this->assertSame( 'D&E', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected the noframes token after the noembed contents.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'NOFRAMES', $processor->get_token_name() ); - $this->assertSame( 'F&G', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected the xmp token after the noframes contents.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'XMP', $processor->get_token_name() ); - $this->assertSame( 'H&I', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected the title token after the script contents.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'TITLE', $processor->get_token_name() ); - $this->assertSame( "A&B\u{00a0}\u{00a9}", $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected the textarea token after the title contents.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'TEXTAREA', $processor->get_token_name() ); - $this->assertSame( "C
Text
HelloWorld
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected the paragraph token.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'P', $processor->get_token_name() ); - - $this->assertTrue( $processor->next_token(), 'Expected the text token.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( '#text', $processor->get_token_name() ); - $this->assertSame( 'Hello', $processor->get_modifiable_text() ); - $this->assertSame( 4, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', 'P', '#text' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_token(), 'Expected the comment token.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertSame( 'note', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_HTML_COMMENT', $processor->get_comment_type() ); - $this->assertSame( 'note', $processor->get_full_comment_text() ); - $this->assertSame( 4, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', 'P', '#comment' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_token(), 'Expected the emphasis token.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'EM', $processor->get_token_name() ); - - $this->assertTrue( $processor->next_token(), 'Expected the nested text token.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( 'World', $processor->get_modifiable_text() ); - $this->assertSame( 5, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', 'P', 'EM', '#text' ), $processor->get_breadcrumbs() ); - } - - /** - * Verifies chunked HTML processor token summaries match next_token() metadata. - * - * @dataProvider data_html_processor_implementations - * - * @param string $implementation Implementation identifier. - */ - public function test_html_processor_reads_token_summary_batches( $implementation ) { - $this->skip_if_native_is_unavailable( $implementation, 'WP_HTML_Native_Processor' ); - - $processor = $this->create_html_processor( - $implementation, - 'Text
Text
Text
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected the processing-instruction-looking comment.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertSame( 'pi.name', $processor->get_tag() ); - $this->assertSame( ' data', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_PI_NODE_LOOKALIKE', $processor->get_comment_type() ); - $this->assertSame( '?pi.name data?', $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#comment' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_token(), 'Expected the invalid processing-instruction-looking comment with a numeric target.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertNull( $processor->get_tag() ); - $this->assertSame( '1 data?', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_INVALID_HTML', $processor->get_comment_type() ); - $this->assertSame( '?1 data?', $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#comment' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_token(), 'Expected the invalid processing-instruction-looking comment without XML-style close.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertNull( $processor->get_tag() ); - $this->assertSame( 'pi data', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_INVALID_HTML', $processor->get_comment_type() ); - $this->assertSame( '?pi data', $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#comment' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_token(), 'Expected the CDATA-looking comment.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertNull( $processor->get_tag() ); - $this->assertSame( 'x', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_CDATA_LOOKALIKE', $processor->get_comment_type() ); - $this->assertSame( '[CDATA[x]]', $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#comment' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_token(), 'Expected the invalid-HTML comment.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertNull( $processor->get_tag() ); - $this->assertSame( 'notdoctype', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_INVALID_HTML', $processor->get_comment_type() ); - $this->assertSame( 'notdoctype', $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#comment' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to skip funky comments and find the paragraph tag.' ); - $this->assertSame( 'P', $processor->get_token_name() ); - } - - /** - * Verifies processor handling of invalid closing and abruptly closed comment tokens. - * - * @dataProvider data_html_processor_implementations - * - * @param string $implementation Implementation identifier. - */ - public function test_html_processor_reads_invalid_closing_and_abrupt_comment_tokens( $implementation ) { - $this->skip_if_native_is_unavailable( $implementation, 'WP_HTML_Native_Processor' ); - - $processor = $this->create_html_processor( - $implementation, - '>1>%bad>/> p>Text
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected the presumptuous tag token.' ); - $this->assertSame( '#presumptuous-tag', $processor->get_token_type() ); - $this->assertSame( '#presumptuous-tag', $processor->get_token_name() ); - $this->assertSame( '', $processor->get_modifiable_text() ); - $this->assertNull( $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#presumptuous-tag' ), $processor->get_breadcrumbs() ); - - foreach ( array( '1', '%bad', '/', ' p', '_x', ':x' ) as $expected_text ) { - $this->assertTrue( $processor->next_token(), 'Expected a funky closing comment token.' ); - $this->assertSame( '#funky-comment', $processor->get_token_type() ); - $this->assertSame( '#funky-comment', $processor->get_token_name() ); - $this->assertSame( $expected_text, $processor->get_modifiable_text() ); - $this->assertSame( $expected_text, $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#funky-comment' ), $processor->get_breadcrumbs() ); - } - - $this->assertTrue( $processor->next_token(), 'Expected the abruptly closed HTML comment.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertSame( '', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_ABRUPTLY_CLOSED_COMMENT', $processor->get_comment_type() ); - $this->assertSame( '', $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#comment' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_token(), 'Expected the dash-abruptly closed HTML comment.' ); - $this->assertSame( '#comment', $processor->get_token_type() ); - $this->assertSame( '#comment', $processor->get_token_name() ); - $this->assertSame( '', $processor->get_modifiable_text() ); - $this->assertSame( 'COMMENT_AS_ABRUPTLY_CLOSED_COMMENT', $processor->get_comment_type() ); - $this->assertSame( '', $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#comment' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to skip invalid tokens and find the paragraph tag.' ); - $this->assertSame( 'P', $processor->get_token_name() ); - } - - /** - * Verifies invalid opening tags stay visible as text in fragment parsing. - * - * @dataProvider data_html_processor_implementations - * - * @param string $implementation Implementation identifier. - */ - public function test_html_processor_reads_invalid_opening_tag_text_tokens( $implementation ) { - $this->skip_if_native_is_unavailable( $implementation, 'WP_HTML_Native_Processor' ); - - $processor = $this->create_html_processor( - $implementation, - 'before & <1><%bad><_x><:x><.x><-x>< p>Text
' - ); - - $this->assertTrue( $processor->next_token(), 'Expected an invalid opening-tag text token.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( '#text', $processor->get_token_name() ); - $this->assertSame( 'before & <1><%bad><_x><:x><.x><-x>< p>', $processor->get_modifiable_text() ); - $this->assertNull( $processor->get_comment_type() ); - $this->assertNull( $processor->get_full_comment_text() ); - $this->assertSame( 3, $processor->get_current_depth() ); - $this->assertSame( array( 'HTML', 'BODY', '#text' ), $processor->get_breadcrumbs() ); - - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to skip invalid text and find the paragraph tag.' ); - $this->assertSame( 'P', $processor->get_token_name() ); - - $processor = $this->create_html_processor( $implementation, '<Text
<' ); - - $this->assertTrue( $processor->next_token(), 'Expected adjacent invalid opening text.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( '<', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected valid paragraph after adjacent invalid opening text.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'P', $processor->get_token_name() ); - - $this->assertTrue( $processor->next_token(), 'Expected paragraph text.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( 'Text', $processor->get_modifiable_text() ); - - $this->assertTrue( $processor->next_token(), 'Expected paragraph closer.' ); - $this->assertSame( '#tag', $processor->get_token_type() ); - $this->assertSame( 'P', $processor->get_token_name() ); - $this->assertTrue( $processor->is_tag_closer() ); - - $this->assertFalse( $processor->next_token(), 'Expected a trailing lone < not to expose a token.' ); - - $processor = $this->create_html_processor( $implementation, 'xText
' ); - - $this->assertTrue( $processor->next_token(), 'Expected consecutive invalid openings to coalesce.' ); - $this->assertSame( '#text', $processor->get_token_type() ); - $this->assertSame( '<< <', $processor->get_modifiable_text() ); - $this->assertTrue( $processor->next_tag(), 'Expected next_tag() to find the valid tag after consecutive invalid openings.' ); - $this->assertSame( 'P', $processor->get_token_name() ); - } - - /** - * Verifies table, list, option, and paragraph fragments synthesize implied tokens. - * - * @dataProvider data_html_processor_implementations - * - * @param string $implementation Implementation identifier. - */ - public function test_html_processor_synthesizes_selected_implied_closers( $implementation ) { - $this->skip_if_native_is_unavailable( $implementation, 'WP_HTML_Native_Processor' ); - - $cases = array( - 'table-cells' => array( - '| A | B |
| A | B |
| A | B |
|---|---|
| C |
| a |
| b |
| A |
| B |
| x |
| x |
| x |
| y |
| x |
| x |
| x |
| y |
| y |
One
Two', - array( - array( 'P', false, array( 'HTML', 'BODY', 'P' ) ), - array( '#text', false, array( 'HTML', 'BODY', 'P', '#text' ) ), - array( 'P', true, array( 'HTML', 'BODY' ) ), - array( 'P', false, array( 'HTML', 'BODY', 'P' ) ), - array( '#text', false, array( 'HTML', 'BODY', 'P', '#text' ) ), - array( 'P', true, array( 'HTML', 'BODY' ) ), - ), - ), - 'paragraph-before-block' => array( - '
Text
Text
TextInline', - array( - array( 'P', false, array( 'HTML', 'BODY', 'P' ) ), - array( '#text', false, array( 'HTML', 'BODY', 'P', '#text' ) ), - array( 'SPAN', false, array( 'HTML', 'BODY', 'P', 'SPAN' ) ), - array( '#text', false, array( 'HTML', 'BODY', 'P', 'SPAN', '#text' ) ), - array( 'SPAN', true, array( 'HTML', 'BODY', 'P' ) ), - array( 'P', true, array( 'HTML', 'BODY' ) ), - ), - ), - 'heading-before-heading' => array( - '
The toolkit stays dependency-free by default, but the experimental Native APIs extension explores faster HTML, XML, and URL-in-text workloads for environments that can load PHP extensions. Try the PHP.wasm smoke test in WordPress Playground, inspect published manifests, and compare CI benchmark snapshots.
- -Experimental performance preview
-Try the experimental wp_native_apis extension in WordPress Playground first. It loads before PHP starts, confirms the WASM extension was registered, and shows quick browser-side timing numbers for hot WP_HTML_Tag_Processor, XMLProcessor, and URLInTextProcessor paths.
Open a ready-made Playground that loads the latest PHP.wasm manifest with php-extension, installs the smoke-test Blueprint, and lands on /native-api-smoke.php.
Use the same manifest locally. The extension flag must be passed before PHP starts.
-npx @wp-playground/cli@latest server \
- --php=8.5 \
- --php-extension=https://wordpress.github.io/php-toolkit/wp_native_apis-wasm-extension/latest/manifest.json \
- --blueprint=https://raw.githubusercontent.com/WordPress/php-toolkit/trunk/extensions/native-apis/playground/blueprint.json
- wp_native_apis WASM extension loaded, then shows whether native classes are available and a few quick timing cards.
- WP_HTML_Tag_ProcessorFast scans and aggregate checks for common tag-processor and fragment-processor workloads.
- Reference → -XMLProcessorStreaming token, tag, namespace, attribute, and inventory summaries without building a DOM tree.
- Reference → -URLInTextProcessorA native candidate scanner for plain-text URL detection, with public handling still validating through the existing parser path.
- Reference → -When wp_native_apis is loaded before the toolkit, wrappers may delegate covered operations to native classes. If the extension is missing, incompatible, or disabled with WP_NATIVE_APIS_DISABLE_DEFAULTS, the same public APIs continue through pure-PHP implementations.
The release index is for pinned manifests, checksums, and exact Playground links. Use latest for a quick trial; use a commit-specific manifest when you need a reproducible demo.
The 10x claim comes from CI benchmark snapshots for the host Rust-backed extension. Playground is the easiest way to see that the extension loads and to get quick browser-side timing numbers; host PHP benchmarks are the better signal for Rust-backed throughput.
-php -d extension=extensions/native-apis/target/release/libwp_native_apis.so \
- bin/benchmark-native-apis.php --iterations=50 --mode=both --disable-native-defaults --require-native
- Benchmark numbers are machine- and workflow-run-specific. Treat them as directional evidence for which workflows benefit from native batching, not as universal throughput guarantees.
-After trying Playground, build the Rust-backed extension for your local host PHP ABI when you want the real native implementation and benchmark numbers. A build made for one PHP ABI cannot be reused for another.
- -Experimental native PHP build
-wp_native_apis for host PHP.Use this guide when you want the Rust-backed Native APIs extension on a local PHP CLI or server. The build is tied to the PHP version and platform you compile against, so treat it as a development and benchmarking path until packaged host releases are published.
- -The host PHP extension is the Rust-backed implementation built with ext-php-rs. When it is loaded before the toolkit bootstrap, covered WP_HTML_Tag_Processor, XMLProcessor, and URLInTextProcessor classes can delegate hot paths to native code. If the extension is absent, incompatible, or disabled, the public PHP APIs keep using their pure-PHP fallback implementations.
php-config.On Ubuntu, the CI benchmark job installs the native build dependencies like this:
-sudo apt-get update
-sudo apt-get install -y clang libclang-dev
-composer install --prefer-dist --no-progress --no-suggest
- On other systems, install equivalent PHP development headers, Rust, Cargo, Clang, and libclang. If php-config or libclang are not discoverable, pass their paths explicitly in the build step.
Run the helper script from the repository root:
-extensions/native-apis/build-extension.sh
- If your target PHP is not the first php-config on PATH, point the build at the exact one you want:
PHP_CONFIG=/path/to/php-config \
-LIBCLANG_PATH=/path/to/libclang/lib \
-extensions/native-apis/build-extension.sh
- The documented Linux build writes the shared object here:
-extensions/native-apis/target/release/libwp_native_apis.so
- Use the same PHP binary at runtime that matches the php-config used at build time. Native PHP extensions are ABI-specific; do not reuse one shared object across PHP versions.
Load the extension before running the native verifier:
-php -d extension=extensions/native-apis/target/release/libwp_native_apis.so \
- extensions/native-apis/tests/verify-native-apis.php
- A successful run means PHP loaded the extension and the expected native classes are registered. If the script reports that the extension is unavailable, re-check the PHP version, php-config path, shared-object path, and libclang setup.
Load the extension before the toolkit bootstrap. The public wrappers decide whether a native delegate is available and fall back to PHP when it is not.
-php -d extension=extensions/native-apis/target/release/libwp_native_apis.so <<'PHP'
-<?php
-require __DIR__ . '/bootstrap.php';
-
-var_dump( is_subclass_of( 'WP_HTML_Tag_Processor', 'WP_HTML_Native_Tag_Processor' ) );
-var_dump( is_subclass_of( 'WordPress\\XML\\XMLProcessor', 'WordPress\\XML\\NativeXMLProcessor' ) );
-PHP
- To force the safe pure-PHP path for comparison, define WP_NATIVE_APIS_DISABLE_DEFAULTS before loading the toolkit:
php -d extension=extensions/native-apis/target/release/libwp_native_apis.so <<'PHP'
-<?php
-define( 'WP_NATIVE_APIS_DISABLE_DEFAULTS', true );
-require __DIR__ . '/bootstrap.php';
-
-var_dump( is_subclass_of( 'WP_HTML_Tag_Processor', 'WP_HTML_Native_Tag_Processor' ) );
-PHP
-The same benchmark command runs in CI for the release page. It requires the native classes, compares PHP and native modes, and disables native defaults so both paths are measured deliberately.
-php -d extension=extensions/native-apis/target/release/libwp_native_apis.so \
- bin/benchmark-native-apis.php \
- --iterations=50 \
- --mode=both \
- --disable-native-defaults \
- --require-native
- Benchmark numbers are machine-specific. Use them to compare PHP and native behavior on the same machine, not as universal throughput guarantees.
-php-config was not foundInstall PHP development headers for the PHP version you want to target, or run the build with PHP_CONFIG=/path/to/php-config.
Confirm the shared object path is correct and that the runtime PHP binary matches the PHP ABI used for compilation.
-Install Clang and libclang, or set LIBCLANG_PATH to the directory containing the libclang library.
Run cd extensions/native-apis && cargo test. This path does not require PHP development headers.
Testing the native extension? The Native APIs page explains the accelerated WP_HTML_Tag_Processor, XMLProcessor, and URLInTextProcessor paths, links to WASM releases for Playground, and shows benchmark summaries from CI.