diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..c5f9246
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,175 @@
+name: Tests
+
+on:
+ push:
+ branches: [ main, develop ]
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ tests:
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: true
+ matrix:
+ php: [8.3, 8.4]
+
+ name: PHP ${{ matrix.php }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: dom, curl, libxml, mbstring, zip, pdo_sqlite
+ coverage: none
+
+ - name: Install dependencies
+ run: composer update --prefer-stable --prefer-dist --no-interaction
+
+ - name: Execute tests
+ run: vendor/bin/pest
+
+ static-analysis:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: 8.3
+ extensions: dom, curl, libxml, mbstring, zip
+ coverage: none
+
+ - name: Install dependencies
+ run: composer update --prefer-stable --prefer-dist --no-interaction
+
+ - name: Run PHPStan
+ run: composer phpstan:ci
+
+ - name: Check coding standards with Pint
+ run: composer lint:check
+
+ e2e:
+ name: E2E
+ runs-on: ubuntu-latest
+ needs: tests
+
+ steps:
+ - name: Checkout skeleton
+ uses: actions/checkout@v4
+ with:
+ repository: Pollora/pollora
+
+ - name: Checkout this package
+ uses: actions/checkout@v4
+ with:
+ path: packages/debugbar
+
+ - name: Setup DDEV
+ uses: ddev/github-action-setup-ddev@v1
+ with:
+ autostart: false
+
+ - name: Configure DDEV
+ run: |
+ ddev config \
+ --project-name=pollora-debugbar \
+ --project-type=wordpress \
+ --docroot=public \
+ --php-version=8.4 \
+ --webserver-type=apache-fpm \
+ --database=mariadb:10.11 \
+ --nodejs-version=22 \
+ --disable-settings-management
+
+ - name: Start DDEV
+ run: ddev start
+
+ - name: Prepare .env
+ run: |
+ cp .env.example .env
+ sed -i \
+ -e 's#^APP_URL=.*#APP_URL=https://pollora-debugbar.ddev.site#' \
+ -e 's#^APP_ENV=.*#APP_ENV=local#' \
+ -e 's#^APP_DEBUG=.*#APP_DEBUG=true#' \
+ -e 's#^DB_CONNECTION=.*#DB_CONNECTION=mysql#' \
+ -e 's/^# DB_HOST=.*/DB_HOST=db/' \
+ -e 's/^# DB_PORT=.*/DB_PORT=3306/' \
+ -e 's/^# DB_DATABASE=.*/DB_DATABASE=db/' \
+ -e 's/^# DB_USERNAME=.*/DB_USERNAME=db/' \
+ -e 's/^# DB_PASSWORD=.*/DB_PASSWORD=db/' \
+ .env
+
+ - name: Install the skeleton with this package checkout
+ run: |
+ ddev composer config repositories.debugbar '{"type": "path", "url": "packages/debugbar", "options": {"symlink": false, "versions": {"pollora/debugbar": "1.0.0"}}}'
+ ddev composer require --dev pollora/debugbar:1.0.0 --with-all-dependencies --no-interaction --no-progress
+
+ - name: Generate application key
+ run: ddev exec php artisan key:generate --no-interaction
+
+ - name: Install WordPress
+ run: |
+ ddev exec php artisan pollora:install --install \
+ --title="Pollora" \
+ --description="Pollora Debugbar CI" \
+ --admin-user=admin \
+ --admin-email=admin@example.com \
+ --admin-password=pollora-ci-password \
+ --locale=en_US \
+ --public=false \
+ --no-interaction
+
+ - name: Add the plugin and package fixtures
+ run: |
+ mkdir -p public/content/mu-plugins
+ cp packages/debugbar/tests/e2e/fixtures/acme-debugbar.php public/content/mu-plugins/
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: npm
+ cache-dependency-path: packages/debugbar/tests/e2e/package-lock.json
+
+ - name: Install the browser tests
+ working-directory: packages/debugbar/tests/e2e
+ run: |
+ npm ci
+ npx playwright install --with-deps chromium
+
+ - name: Trust the site's certificate
+ run: |
+ if command -v mkcert >/dev/null && [ -f "$(mkcert -CAROOT)/rootCA.pem" ]; then
+ echo "NODE_EXTRA_CA_CERTS=$(mkcert -CAROOT)/rootCA.pem" >> "$GITHUB_ENV"
+ else
+ echo "::warning::No mkcert authority on the runner; certificate checks are off for this disposable site"
+ echo "NODE_TLS_REJECT_UNAUTHORIZED=0" >> "$GITHUB_ENV"
+ fi
+
+ - name: Browser tests
+ working-directory: packages/debugbar/tests/e2e
+ env:
+ E2E_HOME_URL: https://pollora-debugbar.ddev.site
+ run: npx playwright test
+
+ - name: Upload the browser test report
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: e2e-report
+ path: |
+ packages/debugbar/tests/e2e/playwright-report
+ packages/debugbar/tests/e2e/test-results
+ retention-days: 14
+
+ - name: Laravel log
+ if: failure()
+ run: tail -n 200 storage/logs/laravel.log || true
diff --git a/.gitignore b/.gitignore
index b95361f..00eced4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,6 @@ composer.lock
patches.lock.json
.idea/
.phpunit.result.cache
+tests/e2e/node_modules/
+tests/e2e/playwright-report/
+tests/e2e/test-results/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..037efbf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+# Pollora Debugbar
+
+Puts WordPress and [Pollora](https://pollora.dev) in [Laravel Debugbar](https://github.com/fruitcake/laravel-debugbar): the `$wpdb` queries next to Eloquent's, the hooks that ran, how WordPress parsed the request, which route or template answered it, and WordPress's phases on the timeline. The long-term goal is to make Query Monitor unnecessary in a Pollora project.
+
+## Installation
+
+```bash
+composer require --dev pollora/debugbar
+```
+
+It brings `fruitcake/laravel-debugbar` with it. Nothing runs unless Laravel Debugbar is enabled for the request (`DEBUGBAR_ENABLED`, or `APP_DEBUG`; never in production or testing), and as a dev dependency it is not installed by `composer install --no-dev`.
+
+## Tabs
+
+| Tab | Origin | Shows |
+| --- | --- | --- |
+| Pollora | Pollora | What answered (`Route::wp()`, the template hierarchy and its view, a Laravel route, or WordPress alone), versions, discovery, modules, theme, async actions registered and queued, WordPress constants and drop-ins |
+| Doctor | Pollora | A **Run doctor** button: `pollora:doctor`'s web checks on demand, errors first |
+| WP Request | WordPress | Rewrite rule, query vars, queried object, main query, true conditionals, template and hierarchy candidates |
+| WP Queries | WordPress | `$wpdb` queries with time, full backtrace, rows, errors, duplicates, slow ones and the main query, grouped by component (core, plugin, theme…) |
+| WP Hooks | WordPress | Hooks that ran, their callbacks, and those Pollora registered |
+| WP HTTP | WordPress | `wp_remote_*` calls: result, time, transport, who made them |
+| WP Cache | WordPress | Object cache hits and misses, transients set, OPcache |
+| WP Capabilities | WordPress | `current_user_can()` checks, each distinct check once with its count |
+| WP Blocks | WordPress | Blocks rendered by type with their time, Pollora's Blade blocks, block bindings |
+| WP Assets | WordPress | Scripts, styles and script modules, header or footer, missing dependencies, Vite builds |
+| WP Languages | WordPress | Locale and the translation files looked for |
+| Timeline | WordPress | `muplugins_loaded` to `shutdown`, beside Debugbar's own measures |
+
+REST and admin-ajax requests end with `exit`, which Laravel Debugbar never sees: this package stores them and sends the `phpdebugbar-id` header, so they appear in the bar's request list of the page that made the call. A `wp_redirect()` keeps its request for the page it leads to.
+
+WordPress queries are traced through core's `log_query_custom_data` and `query` filters, so this works with any `db.php` drop-in, Pollora's included.
+
+Configuration: `php artisan vendor:publish --tag=debugbar-pollora-config`.
+
+## Adding your own data
+
+Tabs from Pollora, WordPress and third parties are told apart: Pollora's tab comes first, WordPress's are prefixed `WP`, and everyone else's come last. Names starting with `wp_` or `pollora` are reserved.
+
+**From a WordPress plugin or theme**, with no dependency on this package (without it, nothing fires the action):
+
+```php
+add_action('pollora/debugbar/register', function ($bar): void {
+ $bar->table('acme_cart', 'Acme cart', fn (): array => acme_cart_rows(), origin: 'acme-shop');
+ $bar->variables('acme_info', 'Acme', fn (): array => ['mode' => 'test'], origin: 'acme-shop');
+ $bar->section('wp_request', 'Acme', fn (): array => ['Cart' => acme_cart_id()]);
+});
+
+do_action('pollora/debugbar/message', 'Cart rebuilt', 'info', ['items' => 3]);
+do_action('pollora/debugbar/start', 'acme-sync');
+do_action('pollora/debugbar/stop', 'acme-sync');
+```
+
+Query Monitor's `qm/debug` … `qm/emergency`, `qm/start` and `qm/stop` actions keep working too.
+
+**From a package or module**, extend `Pollora\Debugbar\Collector` and tag it:
+
+```php
+final class CartCollector extends \Pollora\Debugbar\Collector
+{
+ public function getName(): string { return 'acme_cart'; }
+ public function title(): string { return 'Acme cart'; }
+ public function origin(): string { return 'acme-shop'; }
+ protected function data(): array { return ['items' => 3]; }
+}
+
+$this->app->tag([CartCollector::class], \Pollora\Debugbar\CollectorRegistrar::COLLECTORS_TAG);
+```
+
+`widget()` picks `Widget::Variables`, `Widget::Table` (with `columns()`) or `Widget::Queries`. A `Pollora\Debugbar\Contracts\SectionProvider` tagged `pollora.debugbar.sections` adds a section to an existing tab.
+
+**With Laravel Debugbar alone**, `Debugbar::addCollector()` and `debugbar.custom_collectors` work as usual.
+
+## License
+
+MIT. See [LICENSE](LICENSE).
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..2c7d322
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,82 @@
+{
+ "name": "pollora/debugbar",
+ "description": "Laravel Debugbar for Pollora: WordPress queries, hooks, the template hierarchy and Pollora's internals in the debug bar",
+ "type": "library",
+ "license": "MIT",
+ "keywords": [
+ "wordpress",
+ "laravel",
+ "debugbar",
+ "pollora",
+ "query-monitor"
+ ],
+ "authors": [
+ {
+ "name": "Amphibee",
+ "email": "contact@amphibee.fr"
+ }
+ ],
+ "require": {
+ "php": "^8.3",
+ "fruitcake/laravel-debugbar": "^4.4",
+ "pollora/framework": "^13.35.3"
+ },
+ "require-dev": {
+ "brain/monkey": "^2.7",
+ "laravel/pint": "^1.22",
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^11.1",
+ "pestphp/pest": "^3.8",
+ "php-stubs/wordpress-stubs": "^7.1",
+ "phpstan/phpstan": "^2.1",
+ "szepeviktor/phpstan-wordpress": "^2.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Pollora\\Debugbar\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Pollora\\Debugbar\\Tests\\": "tests/"
+ }
+ },
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Pollora\\Debugbar\\DebugbarServiceProvider"
+ ]
+ }
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "cweagans/composer-patches": true,
+ "pestphp/pest-plugin": true,
+ "phpstan/extension-installer": true,
+ "pollora/helper-overrider": true,
+ "wikimedia/composer-merge-plugin": true
+ }
+ },
+ "scripts": {
+ "test": [
+ "@test:unit",
+ "@phpstan",
+ "@lint:check"
+ ],
+ "test:unit": "pest",
+ "phpstan": "phpstan analyse --memory-limit=1G",
+ "phpstan:ci": "phpstan analyse --memory-limit=1G --no-progress",
+ "lint": "pint",
+ "lint:check": "pint --test"
+ },
+ "scripts-descriptions": {
+ "test": "Run the whole quality gate as CI does",
+ "test:unit": "Run the Pest test suite",
+ "phpstan": "Run PHPStan static analysis",
+ "lint": "Apply Laravel Pint code style fixes",
+ "lint:check": "Verify code style without writing"
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
+}
diff --git a/config/debugbar-pollora.php b/config/debugbar-pollora.php
new file mode 100644
index 0000000..c939bb9
--- /dev/null
+++ b/config/debugbar-pollora.php
@@ -0,0 +1,121 @@
+ env('DEBUGBAR_POLLORA_ENABLED', true),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Tabs
+ |--------------------------------------------------------------------------
+ |
+ | pollora What answered the request, discovery, modules, theme, async
+ | wp_request Rewrite rule, query vars, main query, conditionals, templates,
+ | admin screen, multisite site and switches
+ | wp_queries $wpdb queries (turns SAVEQUERIES on)
+ | wp_hooks Hooks that ran and the callbacks Pollora registered; with
+ | options.wp_hooks.timings, a WP Hook timings tab
+ | wp_timeline WordPress phases on Debugbar's timeline
+ | wp_http HTTP calls made through wp_remote_*
+ | wp_cache Object cache, transients set, OPcache
+ | wp_capabilities current_user_can() checks, aggregated
+ | wp_blocks Blocks rendered and block bindings resolved
+ | wp_assets Scripts, styles, script modules and Vite containers
+ | wp_languages Locale and translation files
+ | doctor A button that runs pollora:doctor's web checks on demand
+ | bridges pollora/debugbar/* and Query Monitor's qm/* actions
+ |
+ */
+
+ 'collectors' => [
+ 'pollora' => env('DEBUGBAR_POLLORA_COLLECTORS_POLLORA', true),
+ 'wp_request' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_REQUEST', true),
+ 'wp_queries' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_QUERIES', true),
+ 'wp_hooks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HOOKS', true),
+ 'wp_timeline' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_TIMELINE', true),
+ 'wp_http' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_HTTP', true),
+ 'wp_cache' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_CACHE', true),
+ 'wp_capabilities' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_CAPABILITIES', true),
+ 'wp_blocks' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_BLOCKS', true),
+ 'wp_assets' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_ASSETS', true),
+ 'wp_languages' => env('DEBUGBAR_POLLORA_COLLECTORS_WP_LANGUAGES', true),
+ 'doctor' => env('DEBUGBAR_POLLORA_COLLECTORS_DOCTOR', true),
+ 'bridges' => env('DEBUGBAR_POLLORA_COLLECTORS_BRIDGES', true),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Pages inside an iframe
+ |--------------------------------------------------------------------------
+ |
+ | The Site Editor and the Customizer show the front end in an iframe. Those
+ | requests are still stored (open them from the bar's request list), but
+ | no bar is printed inside the frame unless this is true.
+ |
+ */
+
+ 'iframes' => env('DEBUGBAR_POLLORA_IFRAMES', false),
+
+ /*
+ |--------------------------------------------------------------------------
+ | wp-admin
+ |--------------------------------------------------------------------------
+ |
+ | WordPress prints admin pages itself, so Laravel Debugbar never shows
+ | there on its own. The bar is printed with the admin footer scripts,
+ | without Debugbar's tabs about a request Laravel answered.
+ |
+ */
+
+ 'admin' => [
+ 'enabled' => env('DEBUGBAR_POLLORA_ADMIN', true),
+ 'hidden_collectors' => ['route', 'views', 'session', 'livewire', 'inertia'],
+ ],
+
+ 'options' => [
+
+ 'wp_queries' => [
+ // Milliseconds from which a query is highlighted; null for never
+ 'slow_threshold' => env('DEBUGBAR_POLLORA_WP_QUERIES_SLOW_THRESHOLD', 50),
+ // Past this many queries, no backtrace is kept
+ 'soft_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_SOFT_LIMIT', 100),
+ // Past this many queries, the rest are left out
+ 'hard_limit' => (int) env('DEBUGBAR_POLLORA_WP_QUERIES_HARD_LIMIT', 500),
+ // Full backtrace, error, rows and component for each query
+ 'trace' => env('DEBUGBAR_POLLORA_WP_QUERIES_TRACE', true),
+ ],
+
+ 'wp_capabilities' => [
+ // Who asked: a backtrace per distinct check
+ 'backtrace' => env('DEBUGBAR_POLLORA_WP_CAPABILITIES_BACKTRACE', false),
+ ],
+
+ 'wp_hooks' => [
+ // Count filters too: an `all` listener runs on every apply_filters()
+ 'count_filters' => env('DEBUGBAR_POLLORA_WP_HOOKS_COUNT_FILTERS', false),
+ // Time every callback (WP Hook timings tab); wraps each callback
+ // in $wp_filter and adds a timer to every call, so off by default
+ 'timings' => env('DEBUGBAR_POLLORA_WP_HOOKS_TIMINGS', false),
+ // How many callbacks the timings tab lists, slowest first
+ 'timings_limit' => (int) env('DEBUGBAR_POLLORA_WP_HOOKS_TIMINGS_LIMIT', 200),
+ ],
+
+ 'bridges' => [
+ // Keep Query Monitor's qm/* logging actions working
+ 'query_monitor' => env('DEBUGBAR_POLLORA_BRIDGES_QUERY_MONITOR', true),
+ ],
+ ],
+];
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..65bc89d
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,12 @@
+includes:
+ - vendor/szepeviktor/phpstan-wordpress/extension.neon
+
+parameters:
+ level: 6
+ paths:
+ - src
+ ignoreErrors:
+ # Pollora's services are read defensively, so that a tab survives an
+ # older framework: method_exists() guards PHPStan cannot narrow on.
+ - '#Call to function method_exists\(\) with .* will always evaluate to true#'
+ reportUnmatchedIgnoredErrors: false
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..1d0f0ba
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,20 @@
+
+
+
+
+ tests/Unit
+
+
+ tests/Feature
+
+
+
+
+ src
+
+
+
diff --git a/pint.json b/pint.json
new file mode 100644
index 0000000..c03d7ed
--- /dev/null
+++ b/pint.json
@@ -0,0 +1,33 @@
+{
+ "preset": "laravel",
+ "rules": {
+ "declare_strict_types": true,
+ "ordered_imports": {
+ "sort_algorithm": "alpha"
+ },
+ "single_quote": true,
+ "array_syntax": {
+ "syntax": "short"
+ },
+ "ordered_class_elements": {
+ "order": [
+ "use_trait",
+ "case",
+ "constant_public",
+ "constant_protected",
+ "constant_private",
+ "property_public",
+ "property_protected",
+ "property_private",
+ "construct",
+ "method_public",
+ "method_protected",
+ "method_private"
+ ]
+ },
+ "no_unused_imports": true
+ },
+ "exclude": [
+ "vendor"
+ ]
+}
\ No newline at end of file
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 0000000..39a2b6e
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "extends": [
+ "config:base"
+ ]
+}
diff --git a/resources/doctor-widget.js b/resources/doctor-widget.js
new file mode 100644
index 0000000..33c08cd
--- /dev/null
+++ b/resources/doctor-widget.js
@@ -0,0 +1,95 @@
+/*
+ * The Doctor tab: a button that runs pollora:doctor's web checks on demand
+ * and lists what they found, errors first.
+ */
+(function () {
+ if (! window.PhpDebugBar || PhpDebugBar.Widgets.PolloraDoctorWidget) {
+ return;
+ }
+
+ const csscls = PhpDebugBar.utils.makecsscls('phpdebugbar-widgets-');
+
+ class PolloraDoctorWidget extends PhpDebugBar.Widget {
+ get className() {
+ return csscls('pollora-doctor');
+ }
+
+ render() {
+ const toolbar = document.createElement('div');
+ toolbar.style.padding = '8px';
+
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.textContent = 'Run doctor';
+
+ const status = document.createElement('span');
+ status.style.marginLeft = '8px';
+ status.textContent = 'Runs pollora:doctor\'s web checks for this site.';
+
+ const table = document.createElement('table');
+ table.classList.add(csscls('tablevar'));
+ table.style.width = '100%';
+
+ toolbar.append(button, status);
+ this.el.append(toolbar, table);
+
+ button.addEventListener('click', async () => {
+ const url = this.get('data')?.url;
+
+ if (! url) {
+ return;
+ }
+
+ button.disabled = true;
+ status.textContent = 'Running…';
+ table.innerHTML = '';
+
+ try {
+ const response = await fetch(url, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
+ const text = await response.text();
+ const isJson = (response.headers.get('content-type') || '').includes('json');
+
+ // A proxy or a restarting server answers with plain text or
+ // HTML: say what came back rather than a JSON syntax error
+ if (! isJson) {
+ throw new Error('HTTP ' + response.status + ': ' + text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120));
+ }
+
+ const body = JSON.parse(text);
+
+ if (! response.ok) {
+ throw new Error(body.message || 'HTTP ' + response.status);
+ }
+
+ const counts = {};
+
+ for (const check of body.checks) {
+ counts[check.status] = (counts[check.status] || 0) + 1;
+
+ const row = document.createElement('tr');
+ row.classList.add(csscls('item'));
+
+ const details = (check.details || []).join(' · ');
+
+ for (const value of [check.status, check.label, [check.summary, details, check.fix ? 'Fix: ' + check.fix : ''].filter(Boolean).join(' — ')]) {
+ const cell = document.createElement('td');
+ cell.classList.add(csscls('value'));
+ cell.textContent = value;
+ row.append(cell);
+ }
+
+ table.append(row);
+ }
+
+ status.textContent = Object.entries(counts).map(([name, count]) => count + ' ' + name).join(', ') || 'No web checks.';
+ } catch (error) {
+ status.textContent = 'The doctor could not run: ' + error.message;
+ } finally {
+ button.disabled = false;
+ }
+ });
+ }
+ }
+
+ PhpDebugBar.Widgets.PolloraDoctorWidget = PolloraDoctorWidget;
+})();
diff --git a/src/Activation.php b/src/Activation.php
new file mode 100644
index 0000000..913cad0
--- /dev/null
+++ b/src/Activation.php
@@ -0,0 +1,59 @@
+runningInConsole()) {
+ return false;
+ }
+
+ $config = $app->make('config');
+
+ if (! $config->get('debugbar-pollora.enabled', true)) {
+ return false;
+ }
+
+ if (! LaravelDebugbar::canBeEnabled()) {
+ return false;
+ }
+
+ // Debugbar merges its config in its own register(), which may come
+ // after this one: read its environment default when the key is not
+ // there yet.
+ $enabled = $config->has('debugbar.enabled')
+ ? value($config->get('debugbar.enabled'))
+ : env('DEBUGBAR_ENABLED');
+
+ return (bool) ($enabled ?? $config->get('app.debug'));
+ }
+
+ /**
+ * Whether the page is loaded inside an iframe: the Site Editor canvas,
+ * the Customizer preview, an embed.
+ *
+ * Browsers say so with `Sec-Fetch-Dest`; `wp_site_preview` covers the
+ * Site Editor in a browser that does not send it.
+ */
+ public static function isFramed(Request $request): bool
+ {
+ return $request->headers->get('Sec-Fetch-Dest') === 'iframe'
+ || $request->query->has('wp_site_preview');
+ }
+}
diff --git a/src/Bridges/MessageBridge.php b/src/Bridges/MessageBridge.php
new file mode 100644
index 0000000..cd98885
--- /dev/null
+++ b/src/Bridges/MessageBridge.php
@@ -0,0 +1,104 @@
+message(...), 10, 3);
+ add_action('pollora/debugbar/start', $this->start(...), 10, 2);
+ add_action('pollora/debugbar/stop', $this->stop(...), 10, 1);
+
+ if (! $queryMonitor) {
+ return;
+ }
+
+ foreach (self::LEVELS as $level) {
+ add_action("qm/{$level}", fn (mixed $message, array $context = []) => $this->message($message, $level, $context), 10, 2);
+ }
+
+ add_action('qm/start', fn (string $name) => $this->start($name), 10, 1);
+ add_action('qm/stop', $this->stop(...), 10, 1);
+ }
+
+ /**
+ * @param array $context
+ */
+ public function message(mixed $message, string $level = 'info', array $context = []): void
+ {
+ $debugbar = ($this->debugbar)();
+
+ if (! $debugbar instanceof LaravelDebugbar) {
+ return;
+ }
+
+ if ($message instanceof \Throwable) {
+ $debugbar->addThrowable($message);
+
+ return;
+ }
+
+ if ($message instanceof \WP_Error) {
+ $message = 'WP_Error: '.$message->get_error_message();
+ }
+
+ $debugbar->addMessage(is_string($message) ? $this->interpolate($message, $context) : $message, $level, $context);
+ }
+
+ public function start(string $name, ?string $label = null): void
+ {
+ ($this->debugbar)()?->startMeasure($name, $label ?? $name, 'wordpress', 'WordPress');
+ }
+
+ public function stop(string $name): void
+ {
+ ($this->debugbar)()?->stopMeasure($name);
+ }
+
+ /**
+ * PSR-3 placeholders (`{user}`) filled from the context, as Query Monitor does.
+ *
+ * @param array $context
+ */
+ private function interpolate(string $message, array $context): string
+ {
+ $replacements = [];
+
+ foreach ($context as $key => $value) {
+ if (is_scalar($value) || $value instanceof \Stringable) {
+ $replacements['{'.$key.'}'] = (string) $value;
+ }
+ }
+
+ return strtr($message, $replacements);
+ }
+}
diff --git a/src/Collector.php b/src/Collector.php
new file mode 100644
index 0000000..a59bf7a
--- /dev/null
+++ b/src/Collector.php
@@ -0,0 +1,213 @@
+}>
+ */
+ private array $sections = [];
+
+ /**
+ * The tab's title in the bar.
+ */
+ abstract public function title(): string;
+
+ /**
+ * Who the data comes from: Origin::POLLORA, Origin::WORDPRESS, or the
+ * owner's own name for a third party (`acme-shop`).
+ */
+ abstract public function origin(): string;
+
+ /**
+ * The widget the data is shown with.
+ */
+ public function widget(): Widget
+ {
+ return Widget::Variables;
+ }
+
+ /**
+ * The tab's icon: one of the icons php-debugbar ships (`box`, `database`,
+ * `link`, `leaf`, `search`, `table`, `tags`, `clock`, `bolt`, `flag`…;
+ * see its resources/icons.css). Any other name shows an empty square.
+ */
+ public function icon(): string
+ {
+ return 'box';
+ }
+
+ /**
+ * The table's columns, keyed by the row field they show (Table widget only).
+ *
+ * @return array
+ */
+ public function columns(): array
+ {
+ return [];
+ }
+
+ /**
+ * Where the tab sits among the tabs of its origin, from 0.
+ */
+ public function position(): int
+ {
+ return 0;
+ }
+
+ /**
+ * Append a section of name => value pairs to this tab (Variables widget).
+ *
+ * @param \Closure(): array $values
+ */
+ public function addSection(string $title, \Closure $values): void
+ {
+ $this->sections[] = ['title' => $title, 'values' => $values];
+ }
+
+ /**
+ * @return array
+ */
+ public function collect(): array
+ {
+ $data = $this->data();
+
+ return match ($this->widget()) {
+ Widget::Variables => $this->variables($data),
+ Widget::Table => [
+ 'data' => ['data' => $data, 'key_map' => $this->columns()],
+ 'count' => count($data),
+ ],
+ Widget::Queries => $data,
+ };
+ }
+
+ /**
+ * @return array>
+ */
+ public function getWidgets(): array
+ {
+ $name = $this->getName();
+ $queries = $this->widget() === Widget::Queries;
+
+ return [
+ $name => [
+ 'icon' => $this->icon(),
+ 'title' => $this->title(),
+ 'tooltip' => Origin::label($this->origin()),
+ 'widget' => match ($this->widget()) {
+ Widget::Variables => match (true) {
+ $this->isJsonVarDumperUsed() => 'PhpDebugBar.Widgets.JsonVariableListWidget',
+ $this->isHtmlVarDumperUsed() => 'PhpDebugBar.Widgets.HtmlVariableListWidget',
+ default => 'PhpDebugBar.Widgets.VariableListWidget',
+ },
+ Widget::Table => 'PhpDebugBar.Widgets.TableVariableListWidget',
+ Widget::Queries => 'PhpDebugBar.Widgets.SQLQueriesWidget',
+ },
+ 'map' => $queries ? $name : "{$name}.data",
+ 'default' => '{}',
+ 'order' => Origin::band($this->origin()) + $this->position(),
+ ],
+ "{$name}:badge" => [
+ 'map' => $queries ? "{$name}.nb_statements" : "{$name}.count",
+ 'default' => 'null',
+ ],
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ public function getAssets(): array
+ {
+ if ($this->widget() !== Widget::Queries) {
+ return [];
+ }
+
+ return [
+ 'css' => 'widgets/sqlqueries/widget.css',
+ 'js' => 'widgets/sqlqueries/widget.js',
+ ];
+ }
+
+ public function reset(): void {}
+
+ /**
+ * A path relative to the project, so the bar names a file someone can open.
+ */
+ protected function relativePath(string $path): string
+ {
+ try {
+ $base = function_exists('base_path') ? rtrim(base_path(), '/').'/' : '';
+ } catch (\Throwable) {
+ // No application to ask: show the path as it is
+ return $path;
+ }
+
+ return $base !== '' && str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
+ }
+
+ /**
+ * What the tab shows, in the shape its widget takes.
+ *
+ * Variables: name => value. Table: row key => [column => value].
+ * Queries: php-debugbar's SQL statement structure.
+ *
+ * @return array
+ */
+ abstract protected function data(): array;
+
+ /**
+ * Name => value pairs as the variable list widgets render them.
+ *
+ * The JSON and HTML widgets write string values as HTML (the HTML one its
+ * keys too), so plain strings are escaped; arrays and objects go through
+ * the var dumper.
+ *
+ * @param array $values
+ * @return array{data: array, count: int|null}
+ */
+ private function variables(array $values): array
+ {
+ foreach ($this->sections as $section) {
+ foreach (($section['values'])() as $key => $value) {
+ $values["{$section['title']} › {$key}"] = $value;
+ }
+ }
+
+ $html = $this->isHtmlVarDumperUsed();
+ $escape = $html || $this->isJsonVarDumperUsed();
+ $formatted = [];
+
+ foreach ($values as $key => $value) {
+ $label = $html ? htmlspecialchars((string) $key) : (string) $key;
+
+ $formatted[$label] = match (true) {
+ is_string($value), is_int($value), is_float($value) => $escape ? htmlspecialchars((string) $value) : (string) $value,
+ is_bool($value) => $value ? 'yes' : 'no',
+ $value === null => '—',
+ default => $this->getDataFormatter()->formatVar($value),
+ };
+ }
+
+ return ['data' => $formatted, 'count' => null];
+ }
+}
diff --git a/src/CollectorRegistrar.php b/src/CollectorRegistrar.php
new file mode 100644
index 0000000..1082352
--- /dev/null
+++ b/src/CollectorRegistrar.php
@@ -0,0 +1,229 @@
+registered) {
+ return;
+ }
+
+ $this->registered = true;
+
+ foreach ($this->builtIns($debugbar) as $collector) {
+ $this->add($debugbar, $collector);
+ }
+
+ foreach ($this->container->tagged(self::COLLECTORS_TAG) as $collector) {
+ if ($collector instanceof DataCollectorInterface) {
+ $this->add($debugbar, $collector);
+ }
+ }
+
+ $registry = new Registry;
+
+ if (function_exists('do_action')) {
+ do_action('pollora/debugbar/register', $registry);
+ }
+
+ foreach ($registry->collectors() as $collector) {
+ $this->add($debugbar, $collector);
+ }
+
+ foreach ($this->container->tagged(self::SECTIONS_TAG) as $provider) {
+ if ($provider instanceof SectionProvider) {
+ $this->section($debugbar, $provider->tab(), $provider->title(), $provider->values(...));
+ }
+ }
+
+ foreach ($registry->sections() as $section) {
+ $this->section($debugbar, $section['tab'], $section['title'], $section['values']);
+ }
+ }
+
+ public function isRegistered(): bool
+ {
+ return $this->registered;
+ }
+
+ /**
+ * @return list
+ */
+ private function builtIns(LaravelDebugbar $debugbar): array
+ {
+ $config = $this->container->make('config');
+ $on = static fn (string $name): bool => (bool) $config->get("debugbar-pollora.collectors.{$name}", true);
+ $collectors = [];
+
+ if ($on('pollora')) {
+ $collectors[] = new PolloraCollector($this->container, $this->container->make(AsyncRecorder::class));
+ }
+
+ if ($on('wp_request')) {
+ $collectors[] = new WpRequestCollector($this->recorder, $this->container->make(SiteRecorder::class), $this->container->make(Components::class));
+ }
+
+ if ($on('wp_queries')) {
+ $threshold = $config->get('debugbar-pollora.options.wp_queries.slow_threshold', 50);
+
+ $collectors[] = new WpQueriesCollector(
+ is_numeric($threshold) ? (float) $threshold : null,
+ (int) $config->get('debugbar-pollora.options.wp_queries.soft_limit', 100),
+ (int) $config->get('debugbar-pollora.options.wp_queries.hard_limit', 500),
+ $this->container->make(Components::class),
+ $config->get('debugbar-pollora.options.wp_queries.trace', true) ? $this->container->make(QueryTracer::class) : null,
+ );
+ }
+
+ if ($on('wp_hooks')) {
+ $collectors[] = new WpHooksCollector($this->recorder, $this->polloraHookServices());
+
+ if ($config->get('debugbar-pollora.options.wp_hooks.timings', false)) {
+ $collectors[] = new WpHookTimingsCollector(
+ $this->container->make(HookTimer::class),
+ $this->container->make(Components::class),
+ (int) $config->get('debugbar-pollora.options.wp_hooks.timings_limit', 200),
+ );
+ }
+ }
+
+ $components = $this->container->make(Components::class);
+
+ if ($on('wp_http')) {
+ $collectors[] = new WpHttpCollector($this->container->make(HttpRecorder::class), $components);
+ }
+
+ if ($on('wp_cache')) {
+ $collectors[] = new WpCacheCollector($this->container->make(CacheRecorder::class), $components);
+ }
+
+ if ($on('wp_capabilities')) {
+ $collectors[] = new WpCapabilitiesCollector($this->container->make(CapabilityRecorder::class), $components);
+ }
+
+ if ($on('wp_blocks')) {
+ $collectors[] = new WpBlocksCollector($this->container->make(BlockRecorder::class));
+ }
+
+ if ($on('wp_assets')) {
+ $collectors[] = new WpAssetsCollector($this->container);
+ }
+
+ if ($on('wp_languages')) {
+ $collectors[] = new WpLanguagesCollector($this->container->make(LanguageRecorder::class));
+ }
+
+ if ($on('doctor')) {
+ $collectors[] = new DoctorCollector('/'.trim((string) $config->get('debugbar.route_prefix', '_debugbar'), '/').'/pollora/doctor');
+ }
+
+ if ($on('wp_timeline') && $debugbar->hasCollector('time')) {
+ $time = $debugbar->getCollector('time');
+
+ if ($time instanceof TimeDataCollector) {
+ $collectors[] = new WpTimelineCollector($this->recorder, $time);
+ }
+ }
+
+ return $collectors;
+ }
+
+ /**
+ * @return list