From 2af8c8da1b2c063f1ff9fd1abfbbad4b2587e820 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 22 Jul 2026 08:41:35 +0530 Subject: [PATCH 1/4] Fix module defaults and publish roadmap --- README.md | 31 ++++++++------- ROADMAP.md | 56 +++++++++++++++++++++++++++ includes/features/class-svg-icons.php | 4 +- 3 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 ROADMAP.md diff --git a/README.md b/README.md index 348e262..4f70dd3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dynamic Functionalities -All-in-one WordPress optimization toolkit with 15+ modules for performance, security, SEO, and content management. Built with modern WordPress coding standards and a clean module-based dashboard. Optimized for performance with lazy-loading, static property caching, and intelligent transients. +All-in-one WordPress optimization toolkit with 16 modules for performance, security, SEO, and content management. Built with modern WordPress coding standards and a clean module-based dashboard. Optimized for performance with modular initialization, static property caching, and intelligent transients. **Version:** 1.4.7 **License:** GPL-2.0-or-later @@ -19,18 +19,8 @@ All-in-one WordPress optimization toolkit with 15+ modules for performance, secu All modules are accessed through a unified dashboard at `wp-admin/admin.php?page=functionalities`. Click any module card to configure its settings. Learn more on [Functionalities Site](https://functionalities.dev) ---- -## Performance & Footprint - -This plugin is built with a "Performance First" philosophy. Unlike many all-in-one plugins that slow down your site, Functionalities is designed to be as lightweight as possible: - -- **Modular & Lazy Loaded:** Using a custom autoloader, the plugin only loads the code required for active modules. If a feature is disabled, its code is never even included in memory. -- **Minimized Database Load:** All module settings are cached in static properties. This ensures that `get_option()` is called at most once per module per request, regardless of how many times a feature is accessed. -- **Zero Frontend Bloat:** Most modules are "Zero Footprint" on the frontend, meaning they load no CSS or JS unless explicitly required (like the Components or Fonts modules). -- **Intelligent Filtering:** Content filters (`the_content`, etc.) use `strpos()` fast-exit checks. If the specific markers or tags for a feature aren't present in your content, the plugin exits immediately without running expensive regular expressions or DOM parsing. -- **Efficient HTML Processing:** We use targeted regex for lightweight tasks (like Schema injection) and only resort to `DOMDocument` when structural manipulation is strictly necessary, ensuring maximum speed. -- **Aggressive Caching:** Heavy operations—such as reading JSON exception lists, calculating file hashes, or managing redirects—are cached using WordPress Transients or versioned options to minimize Disk I/O. +See the [public roadmap](ROADMAP.md) for planned fixes and features. --- @@ -38,7 +28,7 @@ This plugin is built with a "Performance First" philosophy. Unlike many all-in-o This plugin is built with a "Performance First" philosophy. Unlike many all-in-one plugins that slow down your site, Functionalities is designed to be as lightweight as possible: -- **Modular & Lazy Loaded:** Using a custom autoloader, the plugin only loads the code required for active modules. If a feature is disabled, its code is never even included in memory. +- **Modular Initialization:** Each module checks its enabled state before registering its feature hooks. Disabled modules add no frontend assets or feature behavior. - **Minimized Database Load:** All module settings are cached in static properties. This ensures that `get_option()` is called at most once per module per request, regardless of how many times a feature is accessed. - **Zero Frontend Bloat:** Most modules are "Zero Footprint" on the frontend, meaning they load no CSS or JS unless explicitly required (like the Components or Fonts modules). - **Intelligent Filtering:** Content filters (`the_content`, etc.) use `strpos()` fast-exit checks. If the specific markers or tags for a feature aren't present in your content, the plugin exits immediately without running expensive regular expressions or DOM parsing. @@ -274,6 +264,21 @@ Enhanced login protection and security measures for your WordPress site. --- +### Progressive Web App + +Make the site installable with a web app manifest, service worker, offline fallback, and optional install prompt. + +**Features:** +- Configurable app name, colors, icons, display mode, and orientation +- Offline page and versioned runtime caching +- App shortcuts, screenshots, and advanced manifest fields +- Optional install prompt and Web Share Target support +- Root-level manifest and service worker endpoints + +**Navigate to:** `?page=functionalities&module=pwa` + +--- + ## Link Management: Developer Reference ### JSON Preset Format diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..946ffe5 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,56 @@ +# Dynamic Functionalities Roadmap + +This roadmap favors reliability and measurable performance before adding more +surface area. GitHub issues are the source of truth for scope and acceptance +criteria; this file explains the intended release sequence. + +## v1.4.8: Stability and quality gates + +- Fix SVG Icons so a fresh install leaves the module disabled until the user + enables it, matching the explicit-activation policy introduced in v1.2.0. +- Correct README duplication, module coverage, and performance claims. +- Make Task Manager and Redirect Manager JSON updates atomic and concurrency-safe + ([#41](https://github.com/wpgaurav/functionalities/issues/41)). +- Add pull-request CI, supported-PHP checks, distribution validation, coding + standards, and a minimal test foundation + ([#42](https://github.com/wpgaurav/functionalities/issues/42)). + +Release gate: automated checks pass across the supported PHP range, concurrent +file updates cannot lose data, and the manual block-editor iframe smoke test in +`CLAUDE.md` passes in a real WordPress install. + +## v1.5.0: Architecture and portability + +- Introduce a true lazy module registry so disabled feature classes are not + loaded on frontend requests + ([#43](https://github.com/wpgaurav/functionalities/issues/43)). +- Split the monolithic Admin class into module controllers and move remaining + inline assets into versioned files + ([#44](https://github.com/wpgaurav/functionalities/issues/44)). +- Add validated, versioned settings export/import plus a redacted diagnostics + bundle ([#45](https://github.com/wpgaurav/functionalities/issues/45)). + +Release gate: existing settings and admin URLs remain backward compatible, a +configuration round-trips between clean sites, and benchmarks show the lazy +registry reduces included files without increasing option queries. + +## v1.6.0: Operational workflows + +- Add CSV redirect migration and a bounded, privacy-conscious 404 monitor + ([#46](https://github.com/wpgaurav/functionalities/issues/46)). +- Integrate Assumption Detection and Content Integrity with Site Health, + scheduled scans, deduplicated notifications, and useful snapshot diffs + ([#47](https://github.com/wpgaurav/functionalities/issues/47)). + +Release gate: monitoring is opt-in, capped, and cheap on frontend requests; +notifications are rate-limited; imports are validated and all-or-nothing. + +## Planning rules + +- Fix security, data loss, and compatibility regressions before feature work. +- Every feature needs a disabled-by-default path with no frontend assets. +- Persisted-data changes need migration, rollback, concurrency, and uninstall + coverage. +- New admin UI must reuse WordPress patterns and remain keyboard accessible. +- A release is not complete until the GitHub artifact, WordPress.org package, + version metadata, and manual editor smoke test agree. diff --git a/includes/features/class-svg-icons.php b/includes/features/class-svg-icons.php index bb4065b..8ea37a9 100644 --- a/includes/features/class-svg-icons.php +++ b/includes/features/class-svg-icons.php @@ -276,8 +276,8 @@ public static function get_options(): array } $defaults = array( - 'enabled' => true, // Enabled by default - 'icons' => array(), + 'enabled' => false, + 'icons' => array(), ); $opts = (array) \get_option('functionalities_svg_icons', $defaults); self::$options = array_merge($defaults, $opts); From 1a7fd50323c1847a5085ae38e025e30178b9eef1 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 22 Jul 2026 08:45:55 +0530 Subject: [PATCH 2/4] Consolidate roadmap into v1.4.8 --- ROADMAP.md | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 946ffe5..dbaa9a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,10 +1,10 @@ # Dynamic Functionalities Roadmap -This roadmap favors reliability and measurable performance before adding more -surface area. GitHub issues are the source of truth for scope and acceptance -criteria; this file explains the intended release sequence. +All planned fixes and features below target **v1.4.8**. GitHub issues are the +source of truth for scope and acceptance criteria. Work can land in phases, but +the v1.4.8 release should not be tagged until every listed issue is complete. -## v1.4.8: Stability and quality gates +## Phase 1: Stabilize the foundation - Fix SVG Icons so a fresh install leaves the module disabled until the user enables it, matching the explicit-activation policy introduced in v1.2.0. @@ -15,11 +15,7 @@ criteria; this file explains the intended release sequence. standards, and a minimal test foundation ([#42](https://github.com/wpgaurav/functionalities/issues/42)). -Release gate: automated checks pass across the supported PHP range, concurrent -file updates cannot lose data, and the manual block-editor iframe smoke test in -`CLAUDE.md` passes in a real WordPress install. - -## v1.5.0: Architecture and portability +## Phase 2: Improve architecture and portability - Introduce a true lazy module registry so disabled feature classes are not loaded on frontend requests @@ -30,11 +26,7 @@ file updates cannot lose data, and the manual block-editor iframe smoke test in - Add validated, versioned settings export/import plus a redacted diagnostics bundle ([#45](https://github.com/wpgaurav/functionalities/issues/45)). -Release gate: existing settings and admin URLs remain backward compatible, a -configuration round-trips between clean sites, and benchmarks show the lazy -registry reduces included files without increasing option queries. - -## v1.6.0: Operational workflows +## Phase 3: Add operational workflows - Add CSV redirect migration and a bounded, privacy-conscious 404 monitor ([#46](https://github.com/wpgaurav/functionalities/issues/46)). @@ -42,8 +34,17 @@ registry reduces included files without increasing option queries. scheduled scans, deduplicated notifications, and useful snapshot diffs ([#47](https://github.com/wpgaurav/functionalities/issues/47)). -Release gate: monitoring is opt-in, capped, and cheap on frontend requests; -notifications are rate-limited; imports are validated and all-or-nothing. +## v1.4.8 release gate + +- All seven roadmap issues are closed through reviewed changes. +- Automated checks pass across the supported PHP and WordPress range. +- Concurrent file updates cannot lose redirects, tasks, or hit counts. +- Existing settings, hooks, admin URLs, and stored data remain compatible. +- Settings can round-trip between clean sites without exposing sensitive data. +- Monitoring is opt-in, capped, rate-limited, and cheap on frontend requests. +- Redirect imports are validated, previewable, and all-or-nothing. +- The manual block-editor iframe smoke test in `CLAUDE.md` passes in a real + WordPress install. ## Planning rules From 1f088051c093b4f7044fe55f86f0dc770164678f Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Wed, 22 Jul 2026 09:44:51 +0530 Subject: [PATCH 3/4] Ship Dynamic Functionalities 1.4.8 --- .distignore | 4 +- .github/workflows/quality.yml | 74 + README.md | 39 +- ROADMAP.md | 6 +- assets/css/admin.css | 39 + assets/js/admin-redirects.js | 104 + assets/js/admin-tools.js | 78 + assets/js/content-regression.js | 25 +- composer.json | 28 + composer.lock | 2427 ++++++ docs/performance-baseline.md | 22 + functionalities.php | 152 +- includes/admin/class-admin-ui.php | 14 +- includes/admin/class-admin.php | 7192 +--------------- includes/admin/class-module-controller.php | 7232 +++++++++++++++++ includes/admin/class-module-docs.php | 284 +- .../class-redirect-manager-controller.php | 27 + .../class-settings-portability-controller.php | 451 + .../admin/class-site-health-controller.php | 262 + includes/admin/class-svg-icons-controller.php | 27 + .../admin/class-task-manager-controller.php | 27 + includes/admin/trait-admin-ajax.php | 8 +- includes/admin/trait-admin-options.php | 76 +- includes/admin/trait-admin-sanitizers.php | 57 +- includes/core/class-module-registry.php | 210 + .../features/class-assumption-detection.php | 381 +- includes/features/class-block-cleanup.php | 14 +- includes/features/class-components.php | 33 +- .../features/class-content-regression.php | 551 +- includes/features/class-editor-links.php | 16 +- includes/features/class-fonts.php | 50 +- includes/features/class-link-management.php | 150 +- includes/features/class-login-security.php | 86 +- includes/features/class-meta.php | 199 +- includes/features/class-misc.php | 22 +- includes/features/class-pwa.php | 149 +- includes/features/class-redirect-manager.php | 800 +- includes/features/class-schema.php | 42 +- includes/features/class-snippets.php | 45 +- includes/features/class-svg-icons.php | 314 +- includes/features/class-task-manager.php | 564 +- includes/storage/class-atomic-json-store.php | 259 + includes/traits/trait-css-sanitizer.php | 2 +- includes/traits/trait-has-dom-parser.php | 2 +- phpcs.xml.dist | 40 + phpunit.xml.dist | 11 + readme.txt | 19 +- tests/AtomicJsonStoreTest.php | 159 + tests/ContentDiffTest.php | 26 + tests/ModuleDefaultsTest.php | 23 + tests/ModuleRegistryTest.php | 77 + tests/RedirectImportTest.php | 49 + tests/SettingsPortabilityTest.php | 59 + tests/VersionConsistencyTest.php | 34 + tests/bootstrap.php | 81 + tests/fixtures/atomic-worker.php | 27 + tests/fixtures/module-registry-worker.php | 75 + tests/php-lint.php | 42 + uninstall.php | 6 + 59 files changed, 14546 insertions(+), 8726 deletions(-) create mode 100644 .github/workflows/quality.yml create mode 100644 assets/js/admin-redirects.js create mode 100644 assets/js/admin-tools.js create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docs/performance-baseline.md create mode 100644 includes/admin/class-module-controller.php create mode 100644 includes/admin/class-redirect-manager-controller.php create mode 100644 includes/admin/class-settings-portability-controller.php create mode 100644 includes/admin/class-site-health-controller.php create mode 100644 includes/admin/class-svg-icons-controller.php create mode 100644 includes/admin/class-task-manager-controller.php create mode 100644 includes/core/class-module-registry.php create mode 100644 includes/storage/class-atomic-json-store.php create mode 100644 phpcs.xml.dist create mode 100644 phpunit.xml.dist create mode 100644 tests/AtomicJsonStoreTest.php create mode 100644 tests/ContentDiffTest.php create mode 100644 tests/ModuleDefaultsTest.php create mode 100644 tests/ModuleRegistryTest.php create mode 100644 tests/RedirectImportTest.php create mode 100644 tests/SettingsPortabilityTest.php create mode 100644 tests/VersionConsistencyTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/fixtures/atomic-worker.php create mode 100644 tests/fixtures/module-registry-worker.php create mode 100644 tests/php-lint.php diff --git a/.distignore b/.distignore index 3407609..8257854 100644 --- a/.distignore +++ b/.distignore @@ -12,8 +12,8 @@ coverage .editorconfig .eslintrc.js .prettierrc -phpunit.xml -phpcs.xml +phpunit.xml* +phpcs.xml* phpstan.neon # Build tools diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..4753a35 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,74 @@ +name: Quality + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + php-syntax: + name: PHP ${{ matrix.php }} syntax + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: php tests/php-lint.php + + quality: + name: Coding standards and tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: composer:v2 + - run: composer install --no-interaction --prefer-dist + - run: composer phpcs + - run: composer test + - run: | + for file in $(find assets -name '*.js' -type f); do + node --check "$file" + done + - run: bash -n build.sh + + package: + name: Distribution package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: ./build.sh + - run: | + version=$(grep -m1 'Version:' functionalities.php | awk '{print $NF}') + zip="functionalities-${version}.zip" + test -s "$zip" + unzip -t "$zip" + for required in \ + functionalities/functionalities.php \ + functionalities/readme.txt \ + functionalities/uninstall.php \ + functionalities/includes/admin/class-admin.php \ + functionalities/includes/admin/class-module-controller.php \ + functionalities/includes/admin/class-settings-portability-controller.php \ + functionalities/includes/admin/class-site-health-controller.php \ + functionalities/includes/core/class-module-registry.php \ + functionalities/includes/storage/class-atomic-json-store.php \ + functionalities/assets/js/admin-redirects.js \ + functionalities/assets/js/admin-tools.js; do + unzip -Z1 "$zip" | grep -qx "$required" + done + if unzip -Z1 "$zip" | grep -Eq '(^|/)(tests|vendor|node_modules|\.git|\.github)/|composer\.(json|lock)$|\.md$'; then + echo 'Development files leaked into the distribution.' >&2 + exit 1 + fi diff --git a/README.md b/README.md index 4f70dd3..5bee44d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ All-in-one WordPress optimization toolkit with 16 modules for performance, security, SEO, and content management. Built with modern WordPress coding standards and a clean module-based dashboard. Optimized for performance with modular initialization, static property caching, and intelligent transients. -**Version:** 1.4.7 +**Version:** 1.4.8 **License:** GPL-2.0-or-later **Text Domain:** `functionalities` **Pricing:** Free @@ -244,6 +244,8 @@ Manage URL redirects directly from WordPress admin with high-performance file-ba - File-based JSON storage for zero database overhead during redirects - Integrated hit counter for tracking redirect usage - Normalized path matching +- CSV import/export with a validated dry-run preview +- Optional bounded 404 aggregation without visitor identifiers or full referrers **Navigate to:** `?page=functionalities&module=redirect-manager` @@ -378,6 +380,8 @@ functionalities/ │ │ └── svg-icons-editor.css │ └── js/ │ ├── admin.js +│ ├── admin-redirects.js +│ ├── admin-tools.js │ ├── admin-ui.js │ ├── content-regression.js │ └── svg-icons-editor.js @@ -385,7 +389,15 @@ functionalities/ │ ├── admin/ │ │ ├── class-admin.php │ │ ├── class-admin-ui.php -│ │ └── class-module-docs.php +│ │ ├── class-module-controller.php +│ │ ├── class-module-docs.php +│ │ ├── class-redirect-manager-controller.php +│ │ ├── class-settings-portability-controller.php +│ │ ├── class-site-health-controller.php +│ │ ├── class-svg-icons-controller.php +│ │ └── class-task-manager-controller.php +│ ├── core/ +│ │ └── class-module-registry.php │ ├── features/ │ │ ├── class-assumption-detection.php │ │ ├── class-block-cleanup.php @@ -397,11 +409,14 @@ functionalities/ │ │ ├── class-login-security.php │ │ ├── class-meta.php │ │ ├── class-misc.php +│ │ ├── class-pwa.php │ │ ├── class-redirect-manager.php │ │ ├── class-schema.php │ │ ├── class-snippets.php │ │ ├── class-svg-icons.php │ │ └── class-task-manager.php +│ └── storage/ +│ └── class-atomic-json-store.php ├── languages/ ├── exception-urls.json.sample ├── functionalities.php @@ -414,9 +429,23 @@ functionalities/ ## Adding New Modules 1. Create a feature class in `includes/features/class-your-module.php` -2. Add module definition in `Admin::define_modules()` -3. Register settings in `Admin::register_settings()` -4. Initialize in `functionalities.php` +2. Add its definition to `Core\Module_Registry::get_definitions()` +3. Register its settings in the module controller +4. Add focused tests for defaults and any pure helpers + +## Local development checks + +```bash +composer install +composer lint +composer phpcs +composer test +node --check assets/js/admin.js +bash -n build.sh +./build.sh +``` + +The pull-request workflow runs PHP syntax checks on PHP 7.4 through 8.5, coding standards, PHPUnit, JavaScript and shell syntax, version consistency, and distribution ZIP assertions. Example module definition: diff --git a/ROADMAP.md b/ROADMAP.md index dbaa9a9..5b1e706 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,8 @@ # Dynamic Functionalities Roadmap -All planned fixes and features below target **v1.4.8**. GitHub issues are the -source of truth for scope and acceptance criteria. Work can land in phases, but -the v1.4.8 release should not be tagged until every listed issue is complete. +All planned fixes and features below ship in **v1.4.8**. GitHub issues remain the +source of truth for scope and acceptance criteria. The release is tagged only +after automated checks and the real-WordPress smoke gate pass. ## Phase 1: Stabilize the foundation diff --git a/assets/css/admin.css b/assets/css/admin.css index 84afb28..c398a37 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -1543,3 +1543,42 @@ grid-template-columns: repeat(2, 1fr); } } + +.functionalities-tools { + margin-top: 30px; + padding: 20px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; +} + +.functionalities-tools h2 { + margin-top: 0; +} + +.functionalities-tools details { + margin: 14px 0; +} + +.functionalities-tools__modules { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 8px 16px; + margin: 12px 0; +} + +.functionalities-tools__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; +} + +.functionalities-tools__result { + max-height: 320px; + overflow: auto; + margin: 16px 0 0; + padding: 12px; + background: #f6f7f7; + white-space: pre-wrap; +} diff --git a/assets/js/admin-redirects.js b/assets/js/admin-redirects.js new file mode 100644 index 0000000..e43d4ff --- /dev/null +++ b/assets/js/admin-redirects.js @@ -0,0 +1,104 @@ +(function ($) { + 'use strict'; + + if (typeof functionalitiesRedirectAdmin === 'undefined') { + return; + } + + var config = functionalitiesRedirectAdmin; + + function message(response) { + return response && response.data && response.data.message ? response.data.message : 'Error'; + } + + function download(filename, content, type) { + var link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([content], { type: type })); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); + } + + $(function () { + $('#redirect-search').on('input', function () { + var term = $(this).val().toLowerCase(); + $('#redirects-list tr:not(.no-items)').each(function () { + var row = $(this); + var matches = row.find('td:eq(1)').text().toLowerCase().indexOf(term) !== -1 || row.find('td:eq(2)').text().toLowerCase().indexOf(term) !== -1; + row.toggle(matches); + }); + }); + + $('#add-redirect-btn').on('click', function () { + var from = $('#redirect-from').val().trim(); + var to = $('#redirect-to').val().trim(); + if (!from || !to) { + window.alert(config.bothRequired); + return; + } + $.post(config.ajaxUrl, { action: 'functionalities_redirect_add', nonce: config.nonce, from: from, to: to, type: $('#redirect-type').val() }, function (response) { + if (response.success) { window.location.reload(); } else { window.alert(message(response)); } + }); + }); + + $(document).on('change', '.toggle-redirect', function () { + $.post(config.ajaxUrl, { action: 'functionalities_redirect_toggle', nonce: config.nonce, id: $(this).closest('tr').data('id') }); + }); + + $(document).on('click', '.delete-redirect', function () { + if (!window.confirm(config.deletePrompt)) { return; } + var row = $(this).closest('tr'); + $.post(config.ajaxUrl, { action: 'functionalities_redirect_delete', nonce: config.nonce, id: row.data('id') }, function (response) { + if (response.success) { row.fadeOut(function () { row.remove(); }); } + }); + }); + + $('#export-redirects-btn, #export-redirects-csv-btn').on('click', function () { + var format = this.id.indexOf('csv') !== -1 ? 'csv' : 'json'; + $.post(config.ajaxUrl, { action: 'functionalities_redirect_export', nonce: config.nonce, format: format }, function (response) { + if (response.success) { download(response.data.filename, response.data.content, format === 'csv' ? 'text/csv' : 'application/json'); } + }); + }); + + $('#import-redirects-btn, #import-redirects-csv-btn').on('click', function () { + $('#import-format').val(this.id.indexOf('csv') !== -1 ? 'csv' : 'json'); + $('#import-preview').prop('hidden', true).text(''); + $('#confirm-import').prop('disabled', true); + $('#import-modal').css('display', 'flex'); + }); + $('#cancel-import').on('click', function () { $('#import-modal').hide(); }); + + $('#preview-import').on('click', function () { + $.post(config.ajaxUrl, { action: 'functionalities_redirect_import', nonce: config.nonce, document: $('#import-json').val(), format: $('#import-format').val(), dry_run: 1 }, function (response) { + $('#import-preview').prop('hidden', false).text(JSON.stringify(response.data, null, 2)); + $('#confirm-import').prop('disabled', !response.success); + }); + }); + + $('#confirm-import').on('click', function () { + $.post(config.ajaxUrl, { action: 'functionalities_redirect_import', nonce: config.nonce, document: $('#import-json').val(), format: $('#import-format').val() }, function (response) { + if (response.success) { window.location.reload(); } else { window.alert(message(response)); } + }); + }); + + $('#purge-404-btn').on('click', function () { + if (!window.confirm(config.purgePrompt)) { return; } + $.post(config.ajaxUrl, { action: 'functionalities_redirect_404_purge', nonce: config.nonce }, function (response) { + if (response.success) { window.location.reload(); } + }); + }); + + $(document).on('click', '.ignore-404', function () { + var row = $(this).closest('tr'); + $.post(config.ajaxUrl, { action: 'functionalities_redirect_404_ignore', nonce: config.nonce, path: row.data('path') }, function (response) { + if (response.success) { row.remove(); } + }); + }); + + $(document).on('click', '.create-from-404', function () { + $('#redirect-from').val($(this).closest('tr').data('path')); + $('#redirect-to').trigger('focus'); + window.scrollTo({ top: $('#redirect-from').offset().top - 80, behavior: 'smooth' }); + }); + }); +}(jQuery)); diff --git a/assets/js/admin-tools.js b/assets/js/admin-tools.js new file mode 100644 index 0000000..d528c55 --- /dev/null +++ b/assets/js/admin-tools.js @@ -0,0 +1,78 @@ +(function () { + 'use strict'; + + var root = document.querySelector('[data-functionalities-tools]'); + if (!root || typeof functionalitiesTools === 'undefined') { + return; + } + + var fileInput = root.querySelector('[data-tools-file]'); + var previewButton = root.querySelector('[data-tools-preview]'); + var applyButton = root.querySelector('[data-tools-apply]'); + var result = root.querySelector('[data-tools-result]'); + var documentText = ''; + + function request(action, data) { + var body = new FormData(); + body.append('action', action); + body.append('nonce', functionalitiesTools.nonce); + Object.keys(data || {}).forEach(function (key) { + var value = data[key]; + if (Array.isArray(value)) { + value.forEach(function (item) { body.append(key + '[]', item); }); + } else { + body.append(key, value); + } + }); + return fetch(functionalitiesTools.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body }).then(function (response) { return response.json(); }); + } + + function show(data) { + result.hidden = false; + result.textContent = JSON.stringify(data, null, 2); + } + + function download(filename, content) { + var link = document.createElement('a'); + link.href = URL.createObjectURL(new Blob([content], { type: 'application/json' })); + link.download = filename; + link.click(); + URL.revokeObjectURL(link.href); + } + + root.querySelector('[data-tools-export]').addEventListener('click', function () { + var modules = Array.prototype.map.call(root.querySelectorAll('[data-tools-module]:checked'), function (input) { return input.value; }); + request('functionalities_settings_export', { modules: modules, include_code: root.querySelector('[data-tools-include-code]').checked ? '1' : '' }).then(function (response) { + if (response.success) { download(response.data.filename, response.data.content); } else { show(response.data); } + }); + }); + + fileInput.addEventListener('change', function () { + if (!fileInput.files.length) { return; } + fileInput.files[0].text().then(function (text) { + documentText = text; + previewButton.disabled = false; + applyButton.disabled = true; + }); + }); + + previewButton.addEventListener('click', function () { + request('functionalities_settings_preview', { document: documentText, include_code: root.querySelector('[data-tools-include-code]').checked ? '1' : '' }).then(function (response) { + show(response.data); + applyButton.disabled = !response.success; + }); + }); + + applyButton.addEventListener('click', function () { + request('functionalities_settings_import', { document: documentText, include_code: root.querySelector('[data-tools-include-code]').checked ? '1' : '' }).then(function (response) { + show(response.data); + applyButton.disabled = response.success; + }); + }); + + root.querySelector('[data-tools-diagnostics]').addEventListener('click', function () { + request('functionalities_diagnostics').then(function (response) { + if (response.success) { download(response.data.filename, response.data.content); } else { show(response.data); } + }); + }); +}()); diff --git a/assets/js/content-regression.js b/assets/js/content-regression.js index 61d8f7a..4b6b372 100644 --- a/assets/js/content-regression.js +++ b/assets/js/content-regression.js @@ -276,6 +276,28 @@ ); } + /** + * Explain heading-level additions and removals from the snapshot diff. + * + * @param {Object} props Component props. + * @return {JSX.Element|null} Heading change details. + */ + function HeadingChanges( { diff } ) { + if ( ! diff || ( ! diff.headings_added.length && ! diff.headings_removed.length ) ) { + return null; + } + + const added = diff.headings_added.map( ( level ) => 'H' + level ).join( ', ' ); + const removed = diff.headings_removed.map( ( level ) => 'H' + level ).join( ', ' ); + + return wp.element.createElement( + 'div', + { style: { marginTop: '10px', fontSize: '12px', color: '#50575e' } }, + added && wp.element.createElement( 'p', { style: { margin: '0 0 4px' } }, i18n.headingsAdded + ' ' + added ), + removed && wp.element.createElement( 'p', { style: { margin: 0 } }, i18n.headingsRemoved + ' ' + removed ) + ); + } + /** * Main regression panel content component. * @@ -411,7 +433,7 @@ return wp.element.createElement( NoBaseline ); } - const { warnings, has_baseline: hasBaseline, post_settings: postSettings, current, baseline } = status; + const { warnings, has_baseline: hasBaseline, post_settings: postSettings, current, baseline, diff } = status; const hasWarnings = warnings && warnings.length > 0; return wp.element.createElement( @@ -439,6 +461,7 @@ current: current, baseline: baseline } ), + hasBaseline && showActions && wp.element.createElement( HeadingChanges, { diff: diff } ), // Actions (only shown if there are warnings and showActions is true). showActions && hasWarnings && ! ignoreWarnings && wp.element.createElement( diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c41e756 --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "wpgaurav/functionalities", + "description": "Development tooling for the Dynamic Functionalities WordPress plugin.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.1", + "phpcompatibility/phpcompatibility-wp": "^2.1.8", + "phpunit/phpunit": "^9.6.31", + "wp-coding-standards/wpcs": "^3.4" + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true + }, + "scripts": { + "lint": "@php tests/php-lint.php", + "phpcs": "@php -d error_reporting=8191 vendor/bin/phpcs", + "test": "phpunit", + "check": [ + "@lint", + "@phpcs", + "@test" + ] + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..99249c6 --- /dev/null +++ b/composer.lock @@ -0,0 +1,2427 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "cb986f864b0875c8b9975629640b00e8", + "packages": [], + "packages-dev": [ + { + "name": "dealerdirect/phpcodesniffer-composer-installer", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/composer-installer.git", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.2", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.1.0 || ^4.0" + }, + "require-dev": { + "composer/composer": "^2.2", + "ext-json": "*", + "ext-zip": "*", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^9.0 || ^10.0.0@dev", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "composer-plugin", + "extra": { + "class": "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\Plugin" + }, + "autoload": { + "psr-4": { + "PHPCSStandards\\Composer\\Plugin\\Installers\\PHPCodeSniffer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Franck Nijhof", + "email": "opensource@frenck.dev", + "homepage": "https://frenck.dev", + "role": "Open source developer" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/composer-installer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer Standards Composer Installer Plugin", + "keywords": [ + "PHPCodeSniffer", + "PHP_CodeSniffer", + "code quality", + "codesniffer", + "composer", + "installer", + "phpcbf", + "phpcs", + "plugin", + "qa", + "quality", + "standard", + "standards", + "style guide", + "stylecheck", + "tests" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/composer-installer/issues", + "security": "https://github.com/PHPCSStandards/composer-installer/security/policy", + "source": "https://github.com/PHPCSStandards/composer-installer" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2026-05-06T08:26:05+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "shasum": "" + }, + "require": { + "php": "^8.4" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2026-01-05T06:47:08+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpcompatibility/php-compatibility", + "version": "9.3.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibility.git", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243", + "reference": "9fb324479acf6f39452e0655d2429cc0d3914243", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "squizlabs/php_codesniffer": "^2.3 || ^3.0.2" + }, + "conflict": { + "squizlabs/php_codesniffer": "2.6.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.5 || ^5.0 || ^6.0 || ^7.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.5 || This Composer plugin will sort out the PHPCS 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "homepage": "https://github.com/wimg", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCompatibility/PHPCompatibility/graphs/contributors" + } + ], + "description": "A set of sniffs for PHP_CodeSniffer that checks for PHP cross-version compatibility.", + "homepage": "http://techblog.wimgodden.be/tag/codesniffer/", + "keywords": [ + "compatibility", + "phpcs", + "standards" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibility/issues", + "source": "https://github.com/PHPCompatibility/PHPCompatibility" + }, + "time": "2019-12-27T09:44:58+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-paragonie", + "version": "1.3.4", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie.git", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityParagonie/zipball/244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "reference": "244d7b04fc4bc2117c15f5abe23eb933b5f02bbf", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "paragonie/random_compat": "dev-master", + "paragonie/sodium_compat": "dev-master" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A set of rulesets for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by the Paragonie polyfill libraries.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "paragonie", + "phpcs", + "polyfill", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityParagonie" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-09-19T17:43:28+00:00" + }, + { + "name": "phpcompatibility/phpcompatibility-wp", + "version": "2.1.8", + "source": { + "type": "git", + "url": "https://github.com/PHPCompatibility/PHPCompatibilityWP.git", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCompatibility/PHPCompatibilityWP/zipball/7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "reference": "7c8d18b4d90dac9e86b0869a608fa09158e168fa", + "shasum": "" + }, + "require": { + "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/phpcompatibility-paragonie": "^1.0", + "squizlabs/php_codesniffer": "^3.3" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + }, + "suggest": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0 || This Composer plugin will sort out the PHP_CodeSniffer 'installed_paths' automatically.", + "roave/security-advisories": "dev-master || Helps prevent installing dependencies with known security issues." + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Wim Godden", + "role": "lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "lead" + } + ], + "description": "A ruleset for PHP_CodeSniffer to check for PHP cross-version compatibility issues in projects, while accounting for polyfills provided by WordPress.", + "homepage": "http://phpcompatibility.com/", + "keywords": [ + "compatibility", + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/PHPCompatibility/PHPCompatibilityWP/issues", + "security": "https://github.com/PHPCompatibility/PHPCompatibilityWP/security/policy", + "source": "https://github.com/PHPCompatibility/PHPCompatibilityWP" + }, + "funding": [ + { + "url": "https://github.com/PHPCompatibility", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcompatibility", + "type": "thanks_dev" + } + ], + "time": "2025-10-18T00:05:59+00:00" + }, + { + "name": "phpcsstandards/phpcsextra", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", + "reference": "b598aa890815b8df16363271b659d73280129101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", + "reference": "b598aa890815b8df16363271b659d73280129101", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "phpcsstandards/phpcsutils": "^1.2.0", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "phpcsstandards/phpcsdevtools": "^1.2.1", + "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSExtra/graphs/contributors" + } + ], + "description": "A collection of sniffs and standards for use with PHP_CodeSniffer.", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues", + "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSExtra" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-12T23:06:57+00:00" + }, + { + "name": "phpcsstandards/phpcsutils", + "version": "1.2.2", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "shasum": "" + }, + "require": { + "dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0", + "php": ">=5.4", + "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" + }, + "require-dev": { + "ext-filter": "*", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcsstandards/phpcsdevcs": "^1.2.0", + "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0" + }, + "type": "phpcodesniffer-standard", + "extra": { + "branch-alias": { + "dev-stable": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "classmap": [ + "PHPCSUtils/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "Juliette Reinders Folmer", + "homepage": "https://github.com/jrfnl", + "role": "lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHPCSUtils/graphs/contributors" + } + ], + "description": "A suite of utility functions for use with PHP_CodeSniffer", + "homepage": "https://phpcsutils.com/", + "keywords": [ + "PHP_CodeSniffer", + "phpcbf", + "phpcodesniffer-standard", + "phpcs", + "phpcs3", + "phpcs4", + "standards", + "static analysis", + "tokens", + "utility" + ], + "support": { + "docs": "https://phpcsutils.com/", + "issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues", + "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy", + "source": "https://github.com/PHPCSStandards/PHPCSUtils" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-12-08T14:27:58+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.32", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.35", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:48:07+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:03:27+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.4.0", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "469c18ceab4d642b15bad4c65ebf3b307bfd55ab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/469c18ceab4d642b15bad4c65ebf3b307bfd55ab", + "reference": "469c18ceab4d642b15bad4c65ebf3b307bfd55ab", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", + "ext-tokenizer": "*", + "ext-xmlreader": "*", + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.0", + "phpcsstandards/phpcsutils": "^1.2.2", + "squizlabs/php_codesniffer": "^3.13.5" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "phpcsstandards/phpcsdevtools": "^1.2.0", + "phpunit/phpunit": "^8.0 || ^9.0" + }, + "suggest": { + "ext-iconv": "For improved results", + "ext-mbstring": "For improved results" + }, + "type": "phpcodesniffer-standard", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Contributors", + "homepage": "https://github.com/WordPress/WordPress-Coding-Standards/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer rules (sniffs) to enforce WordPress coding conventions", + "keywords": [ + "phpcs", + "standards", + "static analysis", + "wordpress" + ], + "support": { + "issues": "https://github.com/WordPress/WordPress-Coding-Standards/issues", + "source": "https://github.com/WordPress/WordPress-Coding-Standards", + "wiki": "https://github.com/WordPress/WordPress-Coding-Standards/wiki" + }, + "funding": [ + { + "url": "https://opencollective.com/php_codesniffer", + "type": "custom" + } + ], + "time": "2026-07-16T13:05:29+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md new file mode 100644 index 0000000..4eb04c8 --- /dev/null +++ b/docs/performance-baseline.md @@ -0,0 +1,22 @@ +# Module loading baseline + +The v1.4.8 registry reads each module's lightweight option before referencing its feature class. + +| Frontend request | v1.4.7 feature files | v1.4.8 feature files | +| --- | ---: | ---: | +| All modules disabled | 16 | 0 | +| Only Performance & Cleanup enabled | 16 | 1 | + +The isolated registry test records included files for both v1.4.8 scenarios. Shared bootstrap and storage classes remain autoloaded only when their callers need them. Existing option names, enable filters, admin URLs, and module classes remain compatible. + +## WordPress runtime measurement + +The all-disabled scenario was also measured on July 22, 2026 in the same local WordPress Studio site on PHP 8.3. Each result was reproduced across three requests after swapping only the plugin version. + +| Version | Feature files | Memory usage | Peak memory | Database queries | +| --- | ---: | ---: | ---: | ---: | +| v1.4.7 | 16 | 59,171,832 bytes | 59,361,480 bytes | 4 | +| v1.4.8 | 0 | 56,842,472 bytes | 57,032,464 bytes | 4 | +| Difference | -16 | -2,329,360 bytes | -2,329,016 bytes | 0 | + +With all modules disabled, v1.4.8 avoids loading every feature implementation and reduces request memory by about 2.22 MiB in this environment. The database query count is unchanged because WordPress loads these small module flags from its autoloaded options cache. diff --git a/functionalities.php b/functionalities.php index 9f132ae..c1ef07c 100644 --- a/functionalities.php +++ b/functionalities.php @@ -3,7 +3,7 @@ * Plugin Name: Dynamic Functionalities * Plugin URI: https://functionalities.dev * Description: All-in-one WordPress optimization toolkit. 15+ modules for performance, security, SEO, and content management. - * Version: 1.4.7 + * Version: 1.4.8 * Author: Gaurav Tiwari * Author URI: https://gauravtiwari.org * License: GPL-2.0-or-later @@ -13,96 +13,110 @@ * Requires PHP: 7.4 */ -if (!defined('ABSPATH')) { +if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } // Define constants. -if (!defined('FUNCTIONALITIES_VERSION')) { - define('FUNCTIONALITIES_VERSION', '1.4.7'); +if ( ! defined( 'FUNCTIONALITIES_VERSION' ) ) { + define( 'FUNCTIONALITIES_VERSION', '1.4.8' ); } -if (!defined('FUNCTIONALITIES_FILE')) { - define('FUNCTIONALITIES_FILE', __FILE__); +if ( ! defined( 'FUNCTIONALITIES_FILE' ) ) { + define( 'FUNCTIONALITIES_FILE', __FILE__ ); } -if (!defined('FUNCTIONALITIES_DIR')) { - define('FUNCTIONALITIES_DIR', plugin_dir_path(__FILE__)); +if ( ! defined( 'FUNCTIONALITIES_DIR' ) ) { + define( 'FUNCTIONALITIES_DIR', plugin_dir_path( __FILE__ ) ); } -if (!defined('FUNCTIONALITIES_URL')) { - define('FUNCTIONALITIES_URL', plugin_dir_url(__FILE__)); +if ( ! defined( 'FUNCTIONALITIES_URL' ) ) { + define( 'FUNCTIONALITIES_URL', plugin_dir_url( __FILE__ ) ); } // Simple autoloader for plugin classes. -spl_autoload_register(function (string $class) { - if (strpos($class, 'Functionalities\\') !== 0) { - return; - } +spl_autoload_register( + function ( string $class ) { + if ( strpos( $class, 'Functionalities\\' ) !== 0 ) { + return; + } - $parts = explode('\\', $class); - array_shift($parts); // Remove Functionalities + $parts = explode( '\\', $class ); + array_shift( $parts ); // Remove Functionalities - $subpath = ''; - if (count($parts) > 1) { - $subpath = strtolower((string) $parts[0]) . '/'; - } + $subpath = ''; + if ( count( $parts ) > 1 ) { + $subpath = strtolower( (string) $parts[0] ) . '/'; + } - $basename = strtolower(str_replace('_', '-', (string) end($parts))); + $basename = strtolower( str_replace( '_', '-', (string) end( $parts ) ) ); - // Try class- prefix first, then trait- prefix. - $file = FUNCTIONALITIES_DIR . 'includes/' . $subpath . 'class-' . $basename . '.php'; - if (!file_exists($file)) { - $file = FUNCTIONALITIES_DIR . 'includes/' . $subpath . 'trait-' . $basename . '.php'; - } + // Try class- prefix first, then trait- prefix. + $file = FUNCTIONALITIES_DIR . 'includes/' . $subpath . 'class-' . $basename . '.php'; + if ( ! file_exists( $file ) ) { + $file = FUNCTIONALITIES_DIR . 'includes/' . $subpath . 'trait-' . $basename . '.php'; + } - if (file_exists($file)) { - require_once $file; + if ( file_exists( $file ) ) { + require_once $file; + } } -}); +); -// Initialize plugin on init hook. -\add_action('init', function () { - \Functionalities\Admin\Admin::init(); - \Functionalities\Features\Link_Management::init(); - \Functionalities\Features\Block_Cleanup::init(); - \Functionalities\Features\Editor_Links::init(); - \Functionalities\Features\Misc::init(); - \Functionalities\Features\Snippets::init(); - \Functionalities\Features\Schema::init(); - \Functionalities\Features\Components::init(); - \Functionalities\Features\Fonts::init(); - \Functionalities\Features\Meta::init(); - \Functionalities\Features\Content_Regression::init(); - \Functionalities\Features\Assumption_Detection::init(); - \Functionalities\Features\Task_Manager::init(); - \Functionalities\Features\Redirect_Manager::init(); - \Functionalities\Features\Login_Security::init(); - \Functionalities\Features\SVG_Icons::init(); - \Functionalities\Features\PWA::init(); -}, 10); +// Initialize admin and only the modules required for this request. +\add_action( + 'plugins_loaded', + function () { + if ( \is_admin() ) { + \Functionalities\Admin\Admin::init(); + } + \Functionalities\Admin\Site_Health_Controller::init(); + \add_action( 'init', array( '\Functionalities\Core\Module_Registry', 'boot' ), 5 ); + }, + 10 +); + +\add_action( 'updated_option', array( '\Functionalities\Core\Module_Registry', 'handle_option_update' ), 10, 3 ); // Activation hook. -\register_activation_hook(__FILE__, function () { - if (function_exists('flush_rewrite_rules')) { - \flush_rewrite_rules(); +\register_activation_hook( + __FILE__, + function () { + if ( \Functionalities\Core\Module_Registry::is_enabled( 'pwa' ) ) { + \Functionalities\Core\Module_Registry::boot_module( 'pwa' ); + } + if ( function_exists( 'flush_rewrite_rules' ) ) { + \flush_rewrite_rules(); + } } -}); +); // Quick Settings link on the Plugins screen. -\add_filter('plugin_action_links_' . \plugin_basename(__FILE__), function (array $links): array { - $url = \admin_url('admin.php?page=functionalities'); - $links[] = '' . \esc_html__('Settings', 'functionalities') . ''; - return $links; -}); +\add_filter( + 'plugin_action_links_' . \plugin_basename( __FILE__ ), + function ( array $links ): array { + $url = \admin_url( 'admin.php?page=functionalities' ); + $links[] = '' . \esc_html__( 'Settings', 'functionalities' ) . ''; + return $links; + } +); // Add meta links on the Plugins screen (row meta). -\add_filter('plugin_row_meta', function (array $links, string $file): array { - if (\plugin_basename(__FILE__) === $file) { - $links[] = '' . \esc_html__('Support', 'functionalities') . ''; - $links[] = '' . \esc_html__('Report Issues', 'functionalities') . ''; - } - return $links; -}, 10, 2); +\add_filter( + 'plugin_row_meta', + function ( array $links, string $file ): array { + if ( \plugin_basename( __FILE__ ) === $file ) { + $links[] = '' . \esc_html__( 'Support', 'functionalities' ) . ''; + $links[] = '' . \esc_html__( 'Report Issues', 'functionalities' ) . ''; + } + return $links; + }, + 10, + 2 +); -\register_deactivation_hook(__FILE__, function () { - if (function_exists('flush_rewrite_rules')) { - \flush_rewrite_rules(); +\register_deactivation_hook( + __FILE__, + function () { + \wp_clear_scheduled_hook( 'functionalities_assumption_background_scan' ); + if ( function_exists( 'flush_rewrite_rules' ) ) { + \flush_rewrite_rules(); + } } -}); +); diff --git a/includes/admin/class-admin-ui.php b/includes/admin/class-admin-ui.php index dc4c91c..84fa2b7 100644 --- a/includes/admin/class-admin-ui.php +++ b/includes/admin/class-admin-ui.php @@ -27,9 +27,9 @@ class Admin_UI { * @param bool $open Whether to show open by default. Default false. * @return void */ - public static function render_docs_section( string $title, string $content, string $type = 'info', bool $open = false ) : void { + public static function render_docs_section( string $title, string $content, string $type = 'info', bool $open = false ): void { $open_attr = $open ? ' open' : ''; - $class = 'functionalities-docs-accordion functionalities-docs-' . esc_attr( $type ); + $class = 'functionalities-docs-accordion functionalities-docs-' . esc_attr( $type ); echo '
'; echo '' . esc_html( $title ) . ''; @@ -44,7 +44,7 @@ public static function render_docs_section( string $title, string $content, stri * @param array $items List of feature descriptions. * @return void */ - public static function render_features_docs( array $items ) : void { + public static function render_features_docs( array $items ): void { $content = '
    '; foreach ( $items as $item ) { $content .= '
  • ' . esc_html( $item ) . '
  • '; @@ -64,7 +64,7 @@ public static function render_features_docs( array $items ) : void { * @param string $description Usage description. * @return void */ - public static function render_usage_docs( string $description ) : void { + public static function render_usage_docs( string $description ): void { self::render_docs_section( \__( 'How to Use', 'functionalities' ), '

    ' . esc_html( $description ) . '

    ', @@ -78,7 +78,7 @@ public static function render_usage_docs( string $description ) : void { * @param array $hooks Array of hooks with 'name' and 'description' keys. * @return void */ - public static function render_developer_docs( array $hooks ) : void { + public static function render_developer_docs( array $hooks ): void { $content = '
    '; foreach ( $hooks as $hook ) { $content .= '
    ' . esc_html( $hook['name'] ) . '
    '; @@ -99,7 +99,7 @@ public static function render_developer_docs( array $hooks ) : void { * @param string $message Warning message. * @return void */ - public static function render_caution_docs( string $message ) : void { + public static function render_caution_docs( string $message ): void { self::render_docs_section( \__( 'Caution', 'functionalities' ), '

    ' . esc_html( $message ) . '

    ', @@ -113,7 +113,7 @@ public static function render_caution_docs( string $message ) : void { * @param array $config Configuration array with 'features', 'usage', 'caution', 'hooks' keys. * @return void */ - public static function render_module_docs( array $config ) : void { + public static function render_module_docs( array $config ): void { echo '
    '; if ( ! empty( $config['features'] ) ) { diff --git a/includes/admin/class-admin.php b/includes/admin/class-admin.php index b358b92..1098623 100644 --- a/includes/admin/class-admin.php +++ b/includes/admin/class-admin.php @@ -1,6 +1,6 @@ array( - 'title' => \__( 'Task Manager', 'functionalities' ), - 'description' => \__( 'File-based project task management with JSON storage.', 'functionalities' ), - 'icon' => 'dashicons-yes-alt', - 'custom_page' => true, - ), - 'misc' => array( - 'title' => \__( 'Performance & Cleanup', 'functionalities' ), - 'description' => \__( 'Disable bloat, emojis, embeds, heartbeat, and more.', 'functionalities' ), - 'icon' => 'dashicons-performance', - ), - 'snippets' => array( - 'title' => \__( 'Header & Footer', 'functionalities' ), - 'description' => \__( 'Add GA4, custom header and footer code.', 'functionalities' ), - 'icon' => 'dashicons-editor-code', - ), - 'link-management' => array( - 'title' => \__( 'Link Management', 'functionalities' ), - 'description' => \__( 'Control nofollow, new tabs, and link behavior.', 'functionalities' ), - 'icon' => 'dashicons-admin-links', - ), - 'redirect-manager' => array( - 'title' => \__( 'Redirect Manager', 'functionalities' ), - 'description' => \__( 'Create and manage 301/302 URL redirects.', 'functionalities' ), - 'icon' => 'dashicons-randomize', - 'custom_page' => true, - ), - 'block-cleanup' => array( - 'title' => \__( 'Block Cleanup', 'functionalities' ), - 'description' => \__( 'Strip block classes from frontend output.', 'functionalities' ), - 'icon' => 'dashicons-block-default', - ), - 'schema' => array( - 'title' => \__( 'Schema Settings', 'functionalities' ), - 'description' => \__( 'Add microdata to key areas and content.', 'functionalities' ), - 'icon' => 'dashicons-networking', - ), - 'content-regression' => array( - 'title' => \__( 'Content Integrity', 'functionalities' ), - 'description' => \__( 'Detect structural regressions when posts are updated.', 'functionalities' ), - 'icon' => 'dashicons-shield', - ), - 'assumption-detection' => array( - 'title' => \__( 'Assumption Detection', 'functionalities' ), - 'description' => \__( 'Notice when implicit site assumptions stop being true.', 'functionalities' ), - 'icon' => 'dashicons-visibility', - ), - 'login-security' => array( - 'title' => \__( 'Login Security', 'functionalities' ), - 'description' => \__( 'Limit login attempts, customize login page, block XML-RPC.', 'functionalities' ), - 'icon' => 'dashicons-lock', - ), - 'meta' => array( - 'title' => \__( 'Meta & Copyright', 'functionalities' ), - 'description' => \__( 'Copyright, Dublin Core, licensing, and SEO plugin integration.', 'functionalities' ), - 'icon' => 'dashicons-media-text', - ), - 'components' => array( - 'title' => \__( 'Components', 'functionalities' ), - 'description' => \__( 'Define reusable CSS components.', 'functionalities' ), - 'icon' => 'dashicons-layout', - ), - 'fonts' => array( - 'title' => \__( 'Fonts', 'functionalities' ), - 'description' => \__( 'Register custom font families.', 'functionalities' ), - 'icon' => 'dashicons-editor-textcolor', - ), - 'editor-links' => array( - 'title' => \__( 'Editor Link Suggestions', 'functionalities' ), - 'description' => \__( 'Limit link suggestions to selected post types.', 'functionalities' ), - 'icon' => 'dashicons-editor-unlink', - ), - 'svg-icons' => array( - 'title' => \__( 'SVG Icons', 'functionalities' ), - 'description' => \__( 'Upload custom SVG icons and insert them inline in the block editor.', 'functionalities' ), - 'icon' => 'dashicons-flag', - 'custom_page' => true, - ), - 'pwa' => array( - 'title' => \__( 'Progressive Web App', 'functionalities' ), - 'description' => \__( 'Make your site installable and work offline.', 'functionalities' ), - 'icon' => 'dashicons-smartphone', - ), - ); - } - - /** - * Register admin menu. - * - * @return void - */ - public static function register_menu() : void { - $parent_slug = 'functionalities'; - \add_menu_page( - \__( 'Functionalities', 'functionalities' ), - \__( 'Functionalities', 'functionalities' ), - 'manage_options', - $parent_slug, - array( __CLASS__, 'render_main_page' ), - 'dashicons-admin-generic', - 65 - ); - - // Add Dashboard as the first submenu. - \add_submenu_page( - $parent_slug, - \__( 'Dashboard', 'functionalities' ), - \__( 'Dashboard', 'functionalities' ), - 'manage_options', - $parent_slug, - array( __CLASS__, 'render_main_page' ) - ); - - // Add submenus for top modules. - $skip_submenus = array( 'misc', 'assumption-detection', 'login-security', 'block-cleanup' ); - - foreach ( self::$modules as $slug => $module ) { - if ( in_array( $slug, $skip_submenus, true ) ) { - continue; - } - - \add_submenu_page( - $parent_slug, - $module['title'] . ' ‹ ' . \__( 'Functionalities', 'functionalities' ), - $module['title'], - 'manage_options', - 'functionalities-' . $slug, - array( __CLASS__, 'render_main_page' ) - ); - } - } - - /** - * Enqueue admin assets. - * - * @param string $hook Current admin page hook. - * @return void - */ - public static function enqueue_admin_assets( $hook ) : void { - if ( strpos( $hook, 'functionalities' ) === false ) { - return; - } - - $deps = array( 'jquery' ); - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Page detection doesn't require nonce. - $page = isset( $_GET['page'] ) ? \sanitize_key( $_GET['page'] ) : ''; - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Module detection doesn't require nonce. - $module = isset( $_GET['module'] ) ? \sanitize_key( $_GET['module'] ) : ''; - - if ( 'task-manager' === $module || 'functionalities-task-manager' === $page ) { - $deps[] = 'jquery-ui-sortable'; - } - - if ( 'pwa' === $module ) { - \wp_enqueue_media(); - \wp_enqueue_style( 'wp-color-picker' ); - $deps[] = 'wp-color-picker'; - } - - \wp_enqueue_style( - 'functionalities-admin', - FUNCTIONALITIES_URL . 'assets/css/admin.css', - array( 'dashicons' ), - FUNCTIONALITIES_VERSION - ); - - \wp_enqueue_script( - 'functionalities-admin', - FUNCTIONALITIES_URL . 'assets/js/admin.js', - $deps, - FUNCTIONALITIES_VERSION, - true - ); - - // Localize script with AJAX data. - \wp_localize_script( - 'functionalities-admin', - 'functionalitiesAdmin', - array( - 'ajaxUrl' => \admin_url( 'admin-ajax.php' ), - 'runDetectionNonce' => \wp_create_nonce( 'functionalities_run_detection' ), - 'runningText' => \__( 'Running...', 'functionalities' ), - 'runDetectionText' => \__( 'Run Detection Now', 'functionalities' ), - ) - ); - } - - /** - * Render main admin page with module navigation. - * - * @return void - */ - public static function render_main_page() : void { - if ( ! \current_user_can( 'manage_options' ) ) { - \wp_die( \esc_html__( 'Insufficient permissions', 'functionalities' ) ); - } - - // Get current module from URL parameter or page slug. - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Page/module detection doesn't require nonce. - $current_module = isset( $_GET['module'] ) ? \sanitize_key( $_GET['module'] ) : ''; - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Page detection doesn't require nonce. - $page = isset( $_GET['page'] ) ? \sanitize_key( $_GET['page'] ) : ''; - - if ( empty( $current_module ) && strpos( $page, 'functionalities-' ) === 0 ) { - $current_module = str_replace( 'functionalities-', '', $page ); - } - - // If no module or invalid module, show dashboard. - if ( empty( $current_module ) || ! isset( self::$modules[ $current_module ] ) ) { - self::render_dashboard(); - return; - } - - // Render specific module. - self::render_module( $current_module ); - } - - /** - * Render dashboard with module cards. - * - * @return void - */ - /** - * Check if a module is enabled. - * - * @param string $slug Module slug (hyphenated). - * @return bool - */ - private static function is_module_enabled( string $slug ) : bool { - $option_name = 'functionalities_' . str_replace( '-', '_', $slug ); - $opts = (array) \get_option( $option_name, array() ); - - return ! empty( $opts['enabled'] ); - } - - private static function render_dashboard() : void { - ?> -
    -

    -

    - -

    - -
    - $module ) : - $is_active = self::is_module_enabled( $slug ); - ?> -
    -
    - -

    -
    -

    -
    - - - - - - - - -
    -
    - -
    - -
    -

    - - -

    - - -
    - - -
    -

    - - -

    -

    - -

    - -

    - functionalities.dev' - ); - ?> -

    -
    -
    - -
    -

    - - -

    - - - -
    - -
    -
    - [ __CLASS__, 'sanitize_link_management' ], - 'default' => [ - 'enabled' => false, - 'nofollow_external' => false, - 'exceptions' => '', - 'open_external_new_tab' => false, - 'open_internal_new_tab' => false, - 'internal_new_tab_exceptions' => '', - ], - ] - ); - - \add_settings_section( - 'functionalities_link_management_section', - \__( 'Link Management Settings', 'functionalities' ), - array( __CLASS__, 'section_link_management' ), - 'functionalities_link_management' - ); - - \add_settings_field( - 'enabled', - \__( 'Enable Link Management', 'functionalities' ), - function() { - $o = self::get_link_management_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - - \add_settings_field( - 'nofollow_external', - \__( 'Add nofollow to external links', 'functionalities' ), - [ __CLASS__, 'field_nofollow_external' ], - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - - \add_settings_field( - 'exceptions', - \__( 'Exceptions', 'functionalities' ), - [ __CLASS__, 'field_exceptions' ], - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - - // New tab options. - \add_settings_field( - 'open_external_new_tab', - \__( 'Open external links in new tab', 'functionalities' ), - array( __CLASS__, 'field_open_external_new_tab' ), - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - \add_settings_field( - 'open_internal_new_tab', - \__( 'Open internal links in new tab', 'functionalities' ), - array( __CLASS__, 'field_open_internal_new_tab' ), - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - \add_settings_field( - 'internal_new_tab_exceptions', - \__( 'Internal new-tab exceptions (domains)', 'functionalities' ), - array( __CLASS__, 'field_internal_new_tab_exceptions' ), - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - - // Advanced features. - \add_settings_field( - 'json_preset_url', - \__( 'JSON Preset File Path', 'functionalities' ), - array( __CLASS__, 'field_json_preset_url' ), - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - \add_settings_field( - 'enable_developer_filters', - \__( 'Enable Developer Filters', 'functionalities' ), - array( __CLASS__, 'field_enable_developer_filters' ), - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - \add_settings_field( - 'database_update_tool', - \__( 'Database Update Tool', 'functionalities' ), - array( __CLASS__, 'field_database_update_tool' ), - 'functionalities_link_management', - 'functionalities_link_management_section' - ); - - // Block Cleanup settings - \register_setting( - 'functionalities_block_cleanup', - 'functionalities_block_cleanup', - [ - 'sanitize_callback' => [ __CLASS__, 'sanitize_block_cleanup' ], - 'default' => [ - 'enabled' => false, - 'remove_heading_block_class' => false, - 'remove_list_block_class' => false, - 'remove_image_block_class' => false, - 'remove_paragraph_block_class' => false, - 'remove_quote_block_class' => false, - 'remove_table_block_class' => false, - 'remove_separator_block_class' => false, - 'remove_group_block_class' => false, - 'remove_columns_block_class' => false, - 'remove_button_block_class' => false, - 'remove_cover_block_class' => false, - 'remove_media_text_block_class' => false, - 'custom_classes_to_remove' => '', - ], - ] - ); - - \add_settings_section( - 'functionalities_block_cleanup_section', - \__( 'Frontend Block Class Cleanup', 'functionalities' ), - array( __CLASS__, 'section_block_cleanup' ), - 'functionalities_block_cleanup' - ); - - \add_settings_field( - 'enabled', - \__( 'Enable Block Cleanup', 'functionalities' ), - function() { - $o = self::get_block_cleanup_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - - \add_settings_field( - 'remove_heading_block_class', - \__( 'Headings', 'functionalities' ), - [ __CLASS__, 'field_bc_remove_heading' ], - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_list_block_class', - \__( 'Lists', 'functionalities' ), - [ __CLASS__, 'field_bc_remove_list' ], - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_image_block_class', - \__( 'Images', 'functionalities' ), - [ __CLASS__, 'field_bc_remove_image' ], - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_paragraph_block_class', - \__( 'Paragraphs', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_paragraph_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_quote_block_class', - \__( 'Quotes', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_quote_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_table_block_class', - \__( 'Tables', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_table_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_separator_block_class', - \__( 'Separators', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_separator_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_group_block_class', - \__( 'Groups', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_group_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_columns_block_class', - \__( 'Columns', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_columns_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_button_block_class', - \__( 'Buttons', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_button_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_cover_block_class', - \__( 'Covers', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_cover_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'remove_media_text_block_class', - \__( 'Media & Text', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_media_text_block_class'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - \add_settings_field( - 'custom_classes_to_remove', - \__( 'Custom Classes', 'functionalities' ), - function() { - $opts = self::get_block_cleanup_options(); - $val = isset( $opts['custom_classes_to_remove'] ) ? $opts['custom_classes_to_remove'] : ''; - echo ''; - echo '

    ' . \esc_html__( 'Enter additional CSS classes to remove from content output (one per line). Example: my-plugin-class', 'functionalities' ) . '

    '; - }, - 'functionalities_block_cleanup', - 'functionalities_block_cleanup_section' - ); - - // Editor Link Suggestions settings - \register_setting( - 'functionalities_editor_links', - 'functionalities_editor_links', - [ - 'sanitize_callback' => [ __CLASS__, 'sanitize_editor_links' ], - 'default' => [ - 'enabled' => false, - 'enable_limit' => false, - 'post_types' => self::default_editor_link_post_types(), - ], - ] - ); - - \add_settings_section( - 'functionalities_editor_links_section', - \__( 'Editor Link Suggestions', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Control which post types appear in the block editor link search suggestions.', 'functionalities' ) . '

    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

    '; - echo '
      '; - echo '
    • ' . \esc_html__( 'Limits link search results to specific post types when inserting links in Gutenberg', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Reduces clutter by hiding unwanted content types from search results', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Works with posts, pages, and custom post types', 'functionalities' ) . '
    • '; - echo '
    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'How to Use', 'functionalities' ) . '

    '; - echo '

    ' . \esc_html__( 'Enable limitation, then check only the post types you want to appear when searching for links in the editor. Unchecked post types will be hidden from link search results.', 'functionalities' ) . '

    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'For Developers', 'functionalities' ) . '

    '; - echo '

    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_editor_links_enabled — ' . \esc_html__( 'toggle feature', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_editor_links_post_types — ' . \esc_html__( 'modify allowed post types', 'functionalities' ); - echo '

    '; - echo '
    '; - }, - 'functionalities_editor_links' - ); - - \add_settings_field( - 'enabled', - \__( 'Enable Editor Link Suggestions', 'functionalities' ), - function() { - $o = self::get_editor_links_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_editor_links', - 'functionalities_editor_links_section' - ); - - \add_settings_field( - 'enable_limit', - \__( 'Enable limitation', 'functionalities' ), - [ __CLASS__, 'field_el_enable' ], - 'functionalities_editor_links', - 'functionalities_editor_links_section' - ); - \add_settings_field( - 'post_types', - \__( 'Allowed post types', 'functionalities' ), - [ __CLASS__, 'field_el_post_types' ], - 'functionalities_editor_links', - 'functionalities_editor_links_section' - ); - - // Header & Footer (Snippets) settings - \register_setting( - 'functionalities_snippets', - 'functionalities_snippets', - [ - 'sanitize_callback' => [ __CLASS__, 'sanitize_snippets' ], - 'default' => [ - 'enabled' => false, - 'enable_ga4' => false, - 'ga4_id' => '', - 'header' => array(), - 'body_open' => array(), - 'footer' => array(), - ], - ] - ); - - \add_settings_section( - 'functionalities_snippets_section', - \__( 'Header & Footer Code', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Insert custom code snippets into your site header and footer without editing theme files.', 'functionalities' ) . '

    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

    '; - echo '
      '; - echo '
    • ' . \esc_html__( 'Native Google Analytics 4 integration - just enter your Measurement ID', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Multiple snippets per location — each independently toggleable', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Custom code for meta tags, scripts, styles, and tracking codes', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Automatically skips admin pages, feeds, and REST API requests', 'functionalities' ) . '
    • '; - echo '
    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'Allowed Tags', 'functionalities' ) . '

    '; - echo '

    '; - echo '<script>, <style>, <link>, <meta>, <noscript>'; - echo '

    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'For Developers', 'functionalities' ) . '

    '; - echo '

    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_snippets_output_enabled — ' . \esc_html__( 'disable on specific pages', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_snippets_ga4_enabled — ' . \esc_html__( 'control GA4 per user/page', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_snippets_header_code — ' . \esc_html__( 'modify header code', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_snippets_footer_code — ' . \esc_html__( 'modify footer code', 'functionalities' ) . '
    '; - echo \esc_html__( 'Actions:', 'functionalities' ) . ' functionalities_before/after_header/footer_snippets'; - echo '

    '; - echo '
    '; - }, - 'functionalities_snippets' - ); - - \add_settings_field( - 'enabled', - \__( 'Enable Header & Footer', 'functionalities' ), - function() { - $o = self::get_snippets_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_snippets', - 'functionalities_snippets_section' - ); - - \add_settings_field( - 'enable_ga4', - \__( 'Enable Google Analytics 4', 'functionalities' ), - function() { - $o = self::get_snippets_options(); - $checked = ! empty( $o['enable_ga4'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_snippets', - 'functionalities_snippets_section' - ); - \add_settings_field( - 'ga4_id', - \__( 'GA4 Measurement ID', 'functionalities' ), - function() { - $o = self::get_snippets_options(); - $val = isset( $o['ga4_id'] ) ? (string) $o['ga4_id'] : ''; - echo ''; - }, - 'functionalities_snippets', - 'functionalities_snippets_section' - ); - \add_settings_field( - 'header_snippets', - \__( 'Header Snippets', 'functionalities' ), - function() { - self::field_snippets_repeater( 'header', 'wp_head' ); - }, - 'functionalities_snippets', - 'functionalities_snippets_section' - ); - \add_settings_field( - 'body_open_snippets', - \__( 'Body Open Snippets', 'functionalities' ), - function() { - self::field_snippets_repeater( 'body_open', 'wp_body_open' ); - }, - 'functionalities_snippets', - 'functionalities_snippets_section' - ); - \add_settings_field( - 'footer_snippets', - \__( 'Footer Snippets', 'functionalities' ), - function() { - self::field_snippets_repeater( 'footer', 'wp_footer' ); - }, - 'functionalities_snippets', - 'functionalities_snippets_section' - ); - - // Schema settings - \register_setting( - 'functionalities_schema', - 'functionalities_schema', - [ - 'sanitize_callback' => [ __CLASS__, 'sanitize_schema' ], - 'default' => [ - 'enabled' => false, - 'enable_site_schema' => true, - 'site_itemtype' => 'WebPage', - 'enable_header_part' => true, - 'enable_footer_part' => true, - 'enable_article' => true, - 'article_itemtype' => 'Article', - 'add_headline' => true, - 'add_dates' => true, - 'add_author' => true, - 'enable_breadcrumbs' => false, - ], - ] - ); - - \add_settings_section( - 'functionalities_schema_section', - \__( 'Schema Settings', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Add Schema.org microdata attributes to improve search engine understanding of your content.', 'functionalities' ) . '

    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

    '; - echo '
      '; - echo '
    • ' . \esc_html__( 'Adds itemscope/itemtype to the HTML element for page-level schema', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Wraps article content with Article/BlogPosting microdata', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Adds structured data for headlines, dates, and authors', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Marks header and footer regions with WPHeader/WPFooter types', 'functionalities' ) . '
    • '; - echo '
    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'Supported Types', 'functionalities' ) . '

    '; - echo '

    '; - echo '' . \esc_html__( 'Site:', 'functionalities' ) . ' WebPage, AboutPage, ContactPage, Blog, SearchResultsPage
    '; - echo '' . \esc_html__( 'Article:', 'functionalities' ) . ' Article, BlogPosting, NewsArticle'; - echo '

    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'For Developers', 'functionalities' ) . '

    '; - echo '

    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_schema_enabled — ' . \esc_html__( 'toggle all schema output', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_schema_site_type — ' . \esc_html__( 'modify site itemtype', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_schema_article_type — ' . \esc_html__( 'modify article itemtype', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_schema_content — ' . \esc_html__( 'modify wrapped content', 'functionalities' ); - echo '

    '; - echo '
    '; - }, - 'functionalities_schema' - ); - - \add_settings_field( - 'enabled', - \__( 'Enable Schema', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - - \add_settings_field( - 'enable_site_schema', - \__( 'Enable site schema (html tag)', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['enable_site_schema'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'site_itemtype', - \__( 'Site itemtype', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $val = $o['site_itemtype'] ?? 'WebPage'; - $opts = [ 'WebPage', 'AboutPage', 'ContactPage', 'Blog', 'SearchResultsPage' ]; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'enable_header_part', - \__( 'Add WPHeader microdata', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['enable_header_part'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'enable_footer_part', - \__( 'Add WPFooter microdata', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['enable_footer_part'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'enable_article', - \__( 'Enable Article microdata in content', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['enable_article'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'article_itemtype', - \__( 'Article itemtype', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $val = $o['article_itemtype'] ?? 'Article'; - $opts = [ 'Article', 'BlogPosting', 'NewsArticle' ]; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'add_headline', - \__( 'Add headline from first heading', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['add_headline'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'add_dates', - \__( 'Add published/modified dates', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['add_dates'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'add_author', - \__( 'Add author microdata', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['add_author'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - \add_settings_field( - 'enable_breadcrumbs', - \__( 'Enable BreadcrumbList', 'functionalities' ), - function() { - $o = self::get_schema_options(); - $checked = ! empty( $o['enable_breadcrumbs'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_schema', - 'functionalities_schema_section' - ); - - // Components settings - \register_setting( - 'functionalities_components', - 'functionalities_components', - [ - 'sanitize_callback' => [ __CLASS__, 'sanitize_components' ], - 'default' => [ - 'enabled' => false, - 'items' => self::default_components(), - ], - ] - ); - - \add_settings_section( - 'functionalities_components_section', - \__( 'Components', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Create reusable CSS components that are automatically loaded across your entire site.', 'functionalities' ) . '

    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

    '; - echo '
      '; - echo '
    • ' . \esc_html__( 'Define CSS class names and their style rules in one place', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Components are compiled into a single CSS file for optimal caching', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Available on both frontend and admin pages', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Includes default utility components like visually-hidden, skip-link, and marquee', 'functionalities' ) . '
    • '; - echo '
    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'How to Use', 'functionalities' ) . '

    '; - echo '

    ' . \esc_html__( 'Add components by entering a CSS selector (e.g., .my-button) and CSS rules (e.g., background: blue; color: white;). Use the grid below to manage your components.', 'functionalities' ) . '

    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'For Developers', 'functionalities' ) . '

    '; - echo '

    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_components_enabled — ' . \esc_html__( 'toggle output', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_components_items — ' . \esc_html__( 'add components dynamically', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_components_css — ' . \esc_html__( 'modify generated CSS', 'functionalities' ) . '
    '; - echo \esc_html__( 'Action:', 'functionalities' ) . ' functionalities_components_updated — ' . \esc_html__( 'fires when CSS file regenerates', 'functionalities' ); - echo '

    '; - echo '
    '; - }, - 'functionalities_components' - ); - \add_settings_field( - 'enabled', - \__( 'Enable components CSS', 'functionalities' ), - function() { - $o = self::get_components_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_components', - 'functionalities_components_section' - ); - \add_settings_field( - 'items', - \__( 'Component list', 'functionalities' ), - [ __CLASS__, 'field_components_items' ], - 'functionalities_components', - 'functionalities_components_section' - ); - - // Miscellaneous (bloat control) - \register_setting( - 'functionalities_misc', - 'functionalities_misc', - [ - 'sanitize_callback' => [ __CLASS__, 'sanitize_misc' ], - 'default' => [ - 'enabled' => false, - 'disable_block_widgets' => false, - 'load_separate_core_block_assets' => false, - 'disable_emojis' => false, - 'disable_embeds' => false, - 'remove_rest_api_links_head' => false, - 'remove_rsd_wlw_shortlink' => false, - 'remove_generator_meta' => false, - 'disable_xmlrpc' => false, - 'disable_xmlrpc_pingbacks' => false, - 'disable_feeds' => false, - 'disable_gravatars' => false, - 'disable_self_pingbacks' => false, - 'remove_query_strings' => false, - 'remove_dns_prefetch' => false, - 'remove_recent_comments_css' => false, - 'limit_revisions' => false, - 'disable_dashicons_for_guests' => false, - 'disable_heartbeat' => false, - 'disable_admin_bar_front' => false, - 'remove_jquery_migrate' => false, - 'enable_prism_admin' => false, - 'enable_textarea_fullscreen' => false, - ], - ] - ); - - \add_settings_section( - 'functionalities_misc_section', - \__( 'Miscellaneous (Bloat Control)', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Remove unnecessary WordPress features to improve performance and security.', 'functionalities' ) . '

    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

    '; - echo '
      '; - echo '
    • ' . \esc_html__( 'Remove bloat like emojis, oEmbeds, and unnecessary meta tags', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Disable security concerns like XML-RPC and version disclosure', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Improve performance by removing unused scripts and styles', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Add useful enhancements like PrismJS and fullscreen textareas', 'functionalities' ) . '
    • '; - echo '
    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'Caution', 'functionalities' ) . '

    '; - echo '

    ' . \esc_html__( 'Some options may break functionality if plugins depend on them. Test after enabling. Disable Heartbeat API with care if you use auto-save or real-time features.', 'functionalities' ) . '

    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'For Developers', 'functionalities' ) . '

    '; - echo '

    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_misc_options — ' . \esc_html__( 'modify options before application', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_misc_disable_emojis — ' . \esc_html__( 'control emoji removal', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_misc_disable_embeds — ' . \esc_html__( 'control embed removal', 'functionalities' ); - echo '

    '; - echo '
    '; - }, - 'functionalities_misc' - ); - - \add_settings_field( - 'enabled', - \__( 'Enable Performance & Cleanup', 'functionalities' ), - function() { - $o = self::get_misc_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_misc', - 'functionalities_misc_section' - ); - - self::add_misc_field( 'disable_block_widgets', \__( 'Disable block-based widget editor (use classic widgets)', 'functionalities' ) ); - self::add_misc_field( 'load_separate_core_block_assets', \__( 'Load core block styles separately (per-block CSS)', 'functionalities' ) ); - self::add_misc_field( 'disable_emojis', \__( 'Disable emojis scripts/styles', 'functionalities' ) ); - self::add_misc_field( 'disable_embeds', \__( 'Disable oEmbed scripts and endpoints', 'functionalities' ) ); - self::add_misc_field( 'remove_rest_api_links_head', \__( 'Remove REST API and oEmbed discovery links from ', 'functionalities' ) ); - self::add_misc_field( 'remove_rsd_wlw_shortlink', \__( 'Remove RSD, WLWManifest, and shortlink tags', 'functionalities' ) ); - self::add_misc_field( 'remove_generator_meta', \__( 'Remove WordPress version meta (generator)', 'functionalities' ) ); - self::add_misc_field( 'disable_xmlrpc', \__( 'Disable XML-RPC (complete)', 'functionalities' ) ); - self::add_misc_field( 'disable_xmlrpc_pingbacks', \__( 'Disable only XML-RPC Pingbacks', 'functionalities' ) ); - self::add_misc_field( 'disable_feeds', \__( 'Disable RSS/Atom feeds (redirect to homepage)', 'functionalities' ) ); - self::add_misc_field( 'disable_gravatars', \__( 'Disable Gravatars (site-wide)', 'functionalities' ) ); - self::add_misc_field( 'disable_self_pingbacks', \__( 'Disable self-pingbacks (pings to own site)', 'functionalities' ) ); - self::add_misc_field( 'remove_query_strings', \__( 'Remove query strings from static resources (?ver=)', 'functionalities' ) ); - self::add_misc_field( 'remove_dns_prefetch', \__( 'Remove DNS prefetch links from ', 'functionalities' ) ); - self::add_misc_field( 'remove_recent_comments_css', \__( 'Remove Recent Comments inline CSS', 'functionalities' ) ); - self::add_misc_field( 'limit_revisions', \__( 'Limit post revisions to 10', 'functionalities' ) ); - self::add_misc_field( 'disable_dashicons_for_guests', \__( 'Disable Dashicons on frontend for non-logged-in users', 'functionalities' ) ); - self::add_misc_field( 'disable_heartbeat', \__( 'Disable Heartbeat API', 'functionalities' ) ); - self::add_misc_field( 'disable_admin_bar_front', \__( 'Disable admin bar on the frontend', 'functionalities' ) ); - self::add_misc_field( 'remove_jquery_migrate', \__( 'Remove jQuery Migrate from frontend', 'functionalities' ) ); - self::add_misc_field( 'enable_prism_admin', \__( 'Load PrismJS on admin screens (code highlighting where applicable)', 'functionalities' ) ); - self::add_misc_field( 'enable_textarea_fullscreen', \__( 'Enable fullscreen toggle for all backend textareas', 'functionalities' ) ); - - // Fonts settings - \register_setting( - 'functionalities_fonts', - 'functionalities_fonts', - [ - 'sanitize_callback' => [ __CLASS__, 'sanitize_fonts' ], - 'default' => [ - 'enabled' => false, - 'items' => [], - ], - ] - ); - \add_settings_section( - 'functionalities_fonts_section', - \__( 'Font Families', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Self-host custom fonts with automatic @font-face CSS generation.', 'functionalities' ) . '

    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

    '; - echo '
      '; - echo '
    • ' . \esc_html__( 'Generate @font-face CSS rules for self-hosted fonts', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Support for variable fonts with weight ranges (e.g., 100 900)', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'WOFF2 format for modern browsers, optional WOFF fallback', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Configurable font-display strategy (swap, auto, block, etc.)', 'functionalities' ) . '
    • '; - echo '
    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'How to Use', 'functionalities' ) . '

    '; - echo '

    ' . \esc_html__( 'Upload font files to your media library or server, then add font entries below with the family name and file URLs. Use the generated font-family name in your CSS.', 'functionalities' ) . '

    '; - echo '
    '; - - echo '
    '; - echo '

    ' . \esc_html__( 'For Developers', 'functionalities' ) . '

    '; - echo '

    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_fonts_enabled — ' . \esc_html__( 'toggle output', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_fonts_items — ' . \esc_html__( 'add fonts dynamically', 'functionalities' ) . '
    '; - echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_fonts_css — ' . \esc_html__( 'modify generated CSS', 'functionalities' ) . '
    '; - echo \esc_html__( 'Action:', 'functionalities' ) . ' functionalities_fonts_before_output'; - echo '

    '; - echo '
    '; - }, - 'functionalities_fonts' - ); - \add_settings_field( - 'fonts_enabled', - \__( 'Enable fonts output', 'functionalities' ), - function() { - $o = self::get_fonts_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_fonts', - 'functionalities_fonts_section' - ); - \add_settings_field( - 'fonts_items', - \__( 'Families', 'functionalities' ), - [ __CLASS__, 'field_fonts_items' ], - 'functionalities_fonts', - 'functionalities_fonts_section' - ); - \add_settings_field( - 'fonts_assignments', - \__( 'Typography Assignments', 'functionalities' ), - [ __CLASS__, 'field_fonts_assignments' ], - 'functionalities_fonts', - 'functionalities_fonts_section' - ); - - // Login Security settings. - \register_setting( - 'functionalities_login_security', - 'functionalities_login_security', - array( - 'sanitize_callback' => array( __CLASS__, 'sanitize_login_security' ), - 'default' => array( - 'enabled' => false, - 'limit_login_attempts' => true, - 'max_attempts' => 5, - 'lockout_duration' => 15, - 'disable_xmlrpc_auth' => true, - 'disable_application_passwords' => false, - 'hide_login_errors' => true, - 'trust_proxy_headers' => false, - 'custom_logo_url' => '', - 'custom_background_color' => '', - 'custom_form_background' => '', - ), - ) - ); - \add_settings_section( - 'functionalities_login_security_section', - \__( 'Login Security Settings', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Protect your login page and customize its appearance.', 'functionalities' ) . '

    '; - echo '
    '; - echo '

    ' . \esc_html__( 'Security Features', 'functionalities' ) . '

    '; - echo '
      '; - echo '
    • ' . \esc_html__( 'Limit failed login attempts to prevent brute force attacks', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Disable XML-RPC authentication to block remote login attacks', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Hide specific login errors to prevent username enumeration', 'functionalities' ) . '
    • '; - echo '
    • ' . \esc_html__( 'Customize the login page with your logo and colors', 'functionalities' ) . '
    • '; - echo '
    '; - echo '
    '; - $logs = \Functionalities\Features\Login_Security::get_lockout_log( 5 ); - if ( ! empty( $logs ) ) { - echo '
    '; - echo '

    ' . \esc_html__( 'Recent Lockouts', 'functionalities' ) . '

    '; - echo '
      '; - foreach ( $logs as $log ) { - echo '
    • ' . \esc_html( $log['ip'] ) . ' — ' . \esc_html( $log['username'] ) . ' (' . \esc_html( $log['time'] ) . ')
    • '; - } - echo '
    '; - echo '
    '; - } - }, - 'functionalities_login_security' - ); - \add_settings_field( 'login_enabled', \__( 'Enable Login Security', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ''; - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'limit_login_attempts', \__( 'Limit Login Attempts', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ''; - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'max_attempts', \__( 'Max Attempts', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ' ' . \esc_html__( 'failed attempts before lockout', 'functionalities' ); - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'lockout_duration', \__( 'Lockout Duration', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ' ' . \esc_html__( 'minutes', 'functionalities' ); - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'disable_xmlrpc_auth', \__( 'Disable XML-RPC', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ''; - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'disable_application_passwords', \__( 'Disable App Passwords', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ''; - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'hide_login_errors', \__( 'Hide Login Errors', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ''; - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'trust_proxy_headers', \__( 'Trust Proxy Headers', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ''; - echo '

    ' . \esc_html__( 'Only enable when this site sits behind a trusted reverse proxy or CDN (Cloudflare, nginx, etc.). With this OFF, lockouts are keyed by REMOTE_ADDR and cannot be spoofed via headers — the secure default.', 'functionalities' ) . '

    '; - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'custom_logo_url', \__( 'Custom Logo URL', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ''; - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - \add_settings_field( 'custom_background_color', \__( 'Background Color', 'functionalities' ), function() { - $o = self::get_login_security_options(); - echo ''; - }, 'functionalities_login_security', 'functionalities_login_security_section' ); - - // Meta & Copyright settings. - \register_setting( - 'functionalities_meta', - 'functionalities_meta', - array( - 'sanitize_callback' => array( __CLASS__, 'sanitize_meta' ), - 'default' => array( - 'enabled' => false, - 'enable_copyright_meta' => true, - 'enable_dublin_core' => true, - 'enable_license_metabox' => true, - 'enable_schema_integration' => true, - 'default_license' => 'all-rights-reserved', - 'default_license_url' => '', - 'post_types' => array( 'post' ), - 'copyright_holder_type' => 'author', - 'custom_copyright_holder' => '', - 'dc_language' => '', - ), - ) - ); - - \add_settings_section( - 'functionalities_meta_section', - \__( 'Meta & Copyright Settings', 'functionalities' ), - array( __CLASS__, 'section_meta' ), - 'functionalities_meta' - ); - - \add_settings_field( - 'meta_enabled', - \__( 'Enable Meta Module', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'enable_copyright_meta', - \__( 'Copyright Meta Tags', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $checked = ! empty( $o['enable_copyright_meta'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'enable_dublin_core', - \__( 'Dublin Core (DCMI) Metadata', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $checked = ! empty( $o['enable_dublin_core'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'enable_license_metabox', - \__( 'License Metabox', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $checked = ! empty( $o['enable_license_metabox'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'enable_schema_integration', - \__( 'SEO Plugin Schema Integration', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $checked = ! empty( $o['enable_schema_integration'] ) ? 'checked' : ''; - $detected = \Functionalities\Features\Meta::detect_seo_plugin(); - $plugin_names = array( - 'rank-math' => 'Rank Math', - 'yoast' => 'Yoast SEO', - 'seo-framework' => 'The SEO Framework', - 'seopress' => 'SEOPress', - 'aioseo' => 'All in One SEO', - 'none' => \__( 'None detected', 'functionalities' ), - ); - echo ''; - echo '

    '; - if ( $detected !== 'none' ) { - echo '✓ ' . \esc_html__( 'Detected:', 'functionalities' ) . ' ' . \esc_html( $plugin_names[ $detected ] ) . ''; - } else { - echo \esc_html__( 'Supports: Rank Math, Yoast SEO, The SEO Framework, SEOPress, AIOSEO', 'functionalities' ); - } - echo '

    '; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'default_license', - \__( 'Default License', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $val = isset( $o['default_license'] ) ? (string) $o['default_license'] : 'all-rights-reserved'; - $licenses = array( - 'all-rights-reserved' => \__( 'All Rights Reserved', 'functionalities' ), - 'cc-by' => 'CC BY 4.0', - 'cc-by-sa' => 'CC BY-SA 4.0', - 'cc-by-nc' => 'CC BY-NC 4.0', - 'cc-by-nc-sa' => 'CC BY-NC-SA 4.0', - 'cc-by-nd' => 'CC BY-ND 4.0', - 'cc-by-nc-nd' => 'CC BY-NC-ND 4.0', - 'cc0' => 'CC0 1.0 (Public Domain)', - ); - echo ''; - echo '

    ' . \esc_html__( 'Default license for new posts (can be overridden per-post).', 'functionalities' ) . '

    '; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'default_license_url', - \__( 'Custom License URL', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $val = isset( $o['default_license_url'] ) ? (string) $o['default_license_url'] : ''; - echo ''; - echo '

    ' . \esc_html__( 'Custom URL for "All Rights Reserved" license (e.g., your terms/disclaimer page).', 'functionalities' ) . '

    '; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'meta_post_types', - \__( 'Post Types', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $selected = isset( $o['post_types'] ) && is_array( $o['post_types'] ) ? $o['post_types'] : array( 'post' ); - $pts = \get_post_types( array( 'public' => true ), 'objects' ); - echo '
    '; - foreach ( $pts as $name => $obj ) { - $is_checked = in_array( $name, $selected, true ) ? 'checked' : ''; - $label = sprintf( '%s (%s)', $obj->labels->singular_name ?? $name, $name ); - echo ''; - } - echo '
    '; - echo '

    ' . \esc_html__( 'Select post types where meta tags and license metabox should appear.', 'functionalities' ) . '

    '; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'copyright_holder_type', - \__( 'Copyright Holder', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $val = isset( $o['copyright_holder_type'] ) ? (string) $o['copyright_holder_type'] : 'author'; - $options = array( - 'author' => \__( 'Post Author', 'functionalities' ), - 'site' => \__( 'Site Name', 'functionalities' ), - 'custom' => \__( 'Custom Name', 'functionalities' ), - ); - echo ''; - echo '

    ' . \esc_html__( 'Who should be listed as the copyright holder.', 'functionalities' ) . '

    '; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'custom_copyright_holder', - \__( 'Custom Copyright Holder Name', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $val = isset( $o['custom_copyright_holder'] ) ? (string) $o['custom_copyright_holder'] : ''; - echo ''; - echo '

    ' . \esc_html__( 'Used when "Custom Name" is selected above.', 'functionalities' ) . '

    '; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - \add_settings_field( - 'dc_language', - \__( 'Dublin Core Language', 'functionalities' ), - function() { - $o = self::get_meta_options(); - $val = isset( $o['dc_language'] ) ? (string) $o['dc_language'] : ''; - $site_lang = \get_bloginfo( 'language' ); - echo ''; - echo '

    ' . \esc_html__( 'Leave empty to use site language. Use ISO 639 codes (en, en-US, de, etc.).', 'functionalities' ) . '

    '; - }, - 'functionalities_meta', - 'functionalities_meta_section' - ); - - // Content Regression Detection settings. - \register_setting( - 'functionalities_content_regression', - 'functionalities_content_regression', - array( - 'sanitize_callback' => array( __CLASS__, 'sanitize_content_regression' ), - 'default' => array( - 'enabled' => false, - 'post_types' => array( 'post', 'page' ), - 'link_drop_enabled' => true, - 'link_drop_percent' => 30, - 'link_drop_absolute' => 3, - 'exclude_nofollow_links' => false, - 'word_count_enabled' => true, - 'word_count_drop_percent' => 35, - 'word_count_min_age_days' => 30, - 'word_count_compare_average' => false, - 'exclude_shortcodes' => false, - 'heading_enabled' => true, - 'detect_missing_h1' => true, - 'detect_multiple_h1' => true, - 'detect_skipped_levels' => true, - 'snapshot_rolling_count' => 5, - 'show_post_column' => true, - ), - ) - ); - - \add_settings_section( - 'functionalities_content_regression_section', - \__( 'Content Integrity Settings', 'functionalities' ), - array( __CLASS__, 'section_content_regression' ), - 'functionalities_content_regression' - ); - - // Enable module. - \add_settings_field( - 'regression_enabled', - \__( 'Enable Content Integrity', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - // Post types. - \add_settings_field( - 'regression_post_types', - \__( 'Post Types', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $selected = isset( $o['post_types'] ) && is_array( $o['post_types'] ) ? $o['post_types'] : array( 'post', 'page' ); - $pts = \get_post_types( array( 'public' => true ), 'objects' ); - echo '
    '; - foreach ( $pts as $name => $obj ) { - if ( 'attachment' === $name ) { - continue; - } - $is_checked = in_array( $name, $selected, true ) ? 'checked' : ''; - $label = sprintf( '%s (%s)', $obj->labels->singular_name ?? $name, $name ); - echo ''; - } - echo '
    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - // Link drop detection header. - \add_settings_field( - 'link_drop_header', - '' . \__( 'Internal Link Detection', 'functionalities' ) . '', - function() { - echo '

    ' . \esc_html__( 'Detect when internal links are removed from content.', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'link_drop_enabled', - \__( 'Enable Link Detection', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['link_drop_enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'link_drop_percent', - \__( 'Link Drop Threshold (%)', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $val = isset( $o['link_drop_percent'] ) ? (int) $o['link_drop_percent'] : 30; - echo ' %'; - echo '

    ' . \esc_html__( 'Warn if internal links drop by this percentage or more.', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'link_drop_absolute', - \__( 'Link Drop Absolute Threshold', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $val = isset( $o['link_drop_absolute'] ) ? (int) $o['link_drop_absolute'] : 3; - echo ' ' . \esc_html__( 'links', 'functionalities' ); - echo '

    ' . \esc_html__( 'Also warn if this many links are removed (whichever triggers first).', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'exclude_nofollow_links', - \__( 'Exclude Nofollow Links', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['exclude_nofollow_links'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - // Word count detection header. - \add_settings_field( - 'word_count_header', - '' . \__( 'Word Count Detection', 'functionalities' ) . '', - function() { - echo '

    ' . \esc_html__( 'Detect when content is shortened significantly.', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'word_count_enabled', - \__( 'Enable Word Count Detection', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['word_count_enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'word_count_drop_percent', - \__( 'Word Count Drop Threshold (%)', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $val = isset( $o['word_count_drop_percent'] ) ? (int) $o['word_count_drop_percent'] : 35; - echo ' %'; - echo '

    ' . \esc_html__( 'Warn if word count drops by this percentage.', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'word_count_min_age_days', - \__( 'Minimum Post Age (days)', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $val = isset( $o['word_count_min_age_days'] ) ? (int) $o['word_count_min_age_days'] : 30; - echo ' ' . \esc_html__( 'days', 'functionalities' ); - echo '

    ' . \esc_html__( 'Only check word count for posts older than this (to avoid alerts on new posts).', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'exclude_shortcodes', - \__( 'Exclude Shortcodes', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['exclude_shortcodes'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - // Heading detection header. - \add_settings_field( - 'heading_header', - '' . \__( 'Heading Structure Detection', 'functionalities' ) . '', - function() { - echo '

    ' . \esc_html__( 'Detect accessibility issues with heading hierarchy.', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'heading_enabled', - \__( 'Enable Heading Detection', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['heading_enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'detect_missing_h1', - \__( 'Detect Missing H1', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['detect_missing_h1'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'detect_multiple_h1', - \__( 'Detect Multiple H1s', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['detect_multiple_h1'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'detect_skipped_levels', - \__( 'Detect Skipped Levels', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['detect_skipped_levels'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - // Advanced settings header. - \add_settings_field( - 'advanced_header', - '' . \__( 'Advanced Settings', 'functionalities' ) . '', - function() { - echo '

    ' . \esc_html__( 'Configure snapshot and display options.', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'snapshot_rolling_count', - \__( 'Snapshots to Keep', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $val = isset( $o['snapshot_rolling_count'] ) ? (int) $o['snapshot_rolling_count'] : 5; - echo ''; - echo '

    ' . \esc_html__( 'Number of historical snapshots to retain per post.', 'functionalities' ) . '

    '; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - \add_settings_field( - 'show_post_column', - \__( 'Show Post List Column', 'functionalities' ), - function() { - $o = self::get_content_regression_options(); - $checked = ! empty( $o['show_post_column'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_content_regression', - 'functionalities_content_regression_section' - ); - - // Assumption Detection settings. - \register_setting( - 'functionalities_assumption_detection', - 'functionalities_assumption_detection', - array( - 'sanitize_callback' => array( __CLASS__, 'sanitize_assumption_detection' ), - 'default' => array( - 'enabled' => false, - 'detect_schema_collision' => true, - 'detect_analytics_dupe' => true, - 'detect_font_redundancy' => true, - 'detect_inline_css_growth' => true, - 'inline_css_threshold_kb' => 50, - 'detect_jquery_conflicts' => true, - 'detect_meta_duplication' => true, - 'detect_rest_exposure' => true, - 'detect_lazy_load_conflict' => true, - 'detect_mixed_content' => true, - 'detect_missing_security_headers' => true, - 'detect_debug_exposure' => true, - 'detect_cron_issues' => true, - ), - ) - ); - - \add_settings_section( - 'functionalities_assumption_detection_section', - \__( 'Assumption Detection Settings', 'functionalities' ), - array( __CLASS__, 'section_assumption_detection' ), - 'functionalities_assumption_detection' - ); - - \add_settings_field( - 'assumption_enabled', - \__( 'Enable Assumption Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_schema_collision', - \__( 'Schema Collision Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_schema_collision'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_analytics_dupe', - \__( 'Analytics Duplication Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_analytics_dupe'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_font_redundancy', - \__( 'Font Redundancy Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_font_redundancy'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_inline_css_growth', - \__( 'Inline CSS Growth Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_inline_css_growth'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'inline_css_threshold_kb', - \__( 'Inline CSS Threshold (KB)', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $val = isset( $o['inline_css_threshold_kb'] ) ? (int) $o['inline_css_threshold_kb'] : 50; - echo ' KB'; - echo '

    ' . \esc_html__( 'Warn when inline CSS exceeds this size.', 'functionalities' ) . '

    '; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_jquery_conflicts', - \__( 'jQuery Conflict Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_jquery_conflicts'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_meta_duplication', - \__( 'Meta Tag Duplication Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_meta_duplication'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_rest_exposure', - \__( 'REST API Exposure Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_rest_exposure'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_lazy_load_conflict', - \__( 'Lazy Loading Conflict Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_lazy_load_conflict'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_mixed_content', - \__( 'Mixed Content Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_mixed_content'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_missing_security_headers', - \__( 'Security Headers Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_missing_security_headers'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_debug_exposure', - \__( 'Debug Mode Exposure', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_debug_exposure'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detect_cron_issues', - \__( 'Cron Issues Detection', 'functionalities' ), - function() { - $o = self::get_assumption_detection_options(); - $checked = ! empty( $o['detect_cron_issues'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - // Explicit save button field - ensures visibility. - \add_settings_field( - 'assumption_save_settings', - '', - function() { - echo '
    '; - \submit_button( \__( 'Save Settings', 'functionalities' ), 'primary', 'submit', false ); - echo ' '; - echo ''; - echo '
    '; - }, - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - \add_settings_field( - 'detected_assumptions', - \__( 'Detected Assumptions', 'functionalities' ), - array( __CLASS__, 'field_detected_assumptions' ), - 'functionalities_assumption_detection', - 'functionalities_assumption_detection_section' - ); - - // PWA settings. - \register_setting( - 'functionalities_pwa', - 'functionalities_pwa', - array( - 'sanitize_callback' => array( __CLASS__, 'sanitize_pwa' ), - 'default' => array( - 'enabled' => false, - ), - ) - ); - - \add_settings_section( - 'functionalities_pwa_identity', - \__( 'App Identity', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Basic information about your progressive web app.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa' - ); - - \add_settings_field( - 'pwa_enabled', - \__( 'Enable PWA', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - \add_settings_field( - 'pwa_app_name', - \__( 'App Name', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'Full name displayed on install. Leave blank to use site title.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - \add_settings_field( - 'pwa_short_name', - \__( 'Short Name', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'Short name for the home screen (12 chars max).', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - \add_settings_field( - 'pwa_description', - \__( 'Description', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'Shown in app stores and install dialogs. Leave blank to use site tagline.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - \add_settings_field( - 'pwa_start_url', - \__( 'Start URL', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - \add_settings_field( - 'pwa_scope', - \__( 'Scope', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'Navigation scope. "/" means the entire site.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - \add_settings_field( - 'pwa_categories', - \__( 'Categories', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'Comma-separated W3C categories (e.g. news, education, business).', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - \add_settings_field( - 'pwa_display', - \__( 'Display Mode', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $modes = array( 'standalone', 'fullscreen', 'minimal-ui', 'browser' ); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - \add_settings_field( - 'pwa_orientation', - \__( 'Orientation', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $orientations = array( 'any', 'portrait', 'landscape', 'portrait-primary', 'landscape-primary' ); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_identity' - ); - - // Appearance section. - \add_settings_section( - 'functionalities_pwa_appearance', - \__( 'Appearance & Icons', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Theme colors and app icons. Icons must be square PNG files.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa' - ); - - \add_settings_field( - 'pwa_theme_color', - \__( 'Theme Color', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_appearance' - ); - - \add_settings_field( - 'pwa_background_color', - \__( 'Background Color', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_appearance' - ); - - \add_settings_field( - 'pwa_icon_512', - \__( 'Icon 512x512', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - self::render_media_field( 'functionalities_pwa[icon_512]', $o['icon_512'], \__( 'Primary app icon (required for installability).', 'functionalities' ) ); - }, - 'functionalities_pwa', - 'functionalities_pwa_appearance' - ); - - \add_settings_field( - 'pwa_icon_192', - \__( 'Icon 192x192', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - self::render_media_field( 'functionalities_pwa[icon_192]', $o['icon_192'], \__( 'Smaller icon for home screen and splash.', 'functionalities' ) ); - }, - 'functionalities_pwa', - 'functionalities_pwa_appearance' - ); - - \add_settings_field( - 'pwa_maskable_icon_512', - \__( 'Maskable Icon 512x512', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - self::render_media_field( 'functionalities_pwa[maskable_icon_512]', $o['maskable_icon_512'], \__( 'Maskable icon for adaptive icon support (safe zone padding recommended).', 'functionalities' ) ); - }, - 'functionalities_pwa', - 'functionalities_pwa_appearance' - ); - - \add_settings_field( - 'pwa_maskable_icon_192', - \__( 'Maskable Icon 192x192', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - self::render_media_field( 'functionalities_pwa[maskable_icon_192]', $o['maskable_icon_192'], \__( 'Smaller maskable icon.', 'functionalities' ) ); - }, - 'functionalities_pwa', - 'functionalities_pwa_appearance' - ); - - // Install Prompt section. - \add_settings_section( - 'functionalities_pwa_prompt', - \__( 'Install Prompt', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Customize the in-page install prompt shown to visitors.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa' - ); - - \add_settings_field( - 'pwa_install_prompt', - \__( 'Show Install Prompt', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $checked = ! empty( $o['install_prompt'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_prompt' - ); - - \add_settings_field( - 'pwa_prompt_title', - \__( 'Prompt Title', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_prompt' - ); - - \add_settings_field( - 'pwa_prompt_text', - \__( 'Prompt Text', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_prompt' - ); - - \add_settings_field( - 'pwa_prompt_button', - \__( 'Button Text', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_prompt' - ); - - \add_settings_field( - 'pwa_prompt_dismiss', - \__( 'Dismiss Text', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_prompt' - ); - - \add_settings_field( - 'pwa_prompt_position', - \__( 'Position', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $positions = array( - 'bottom' => \__( 'Bottom', 'functionalities' ), - 'top' => \__( 'Top', 'functionalities' ), - 'center' => \__( 'Center (modal)', 'functionalities' ), - ); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_prompt' - ); - - \add_settings_field( - 'pwa_prompt_style', - \__( 'Style', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $styles = array( - 'banner' => \__( 'Banner', 'functionalities' ), - 'card' => \__( 'Card', 'functionalities' ), - ); - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_prompt' - ); - - \add_settings_field( - 'pwa_prompt_frequency', - \__( 'Re-show After (days)', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'Days to wait before showing prompt again after dismissal.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_prompt' - ); - - // Offline & Caching section. - \add_settings_section( - 'functionalities_pwa_caching', - \__( 'Offline & Caching', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Service worker caching and offline behavior.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa' - ); - - \add_settings_field( - 'pwa_cache_version', - \__( 'Cache Version', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'Increment to force cache refresh (e.g. v1, v2).', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_caching' - ); - - \add_settings_field( - 'pwa_precache_urls', - \__( 'Precache URLs', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'One URL per line. These pages will be cached immediately when the service worker installs.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_caching' - ); - - // Shortcuts section. - \add_settings_section( - 'functionalities_pwa_shortcuts', - \__( 'Shortcuts', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'App shortcut links shown on long-press of the app icon. Up to 4 shortcuts.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa' - ); - - \add_settings_field( - 'pwa_shortcuts', - \__( 'Shortcuts', 'functionalities' ), - array( __CLASS__, 'field_pwa_shortcuts' ), - 'functionalities_pwa', - 'functionalities_pwa_shortcuts' - ); - - // Screenshots section. - \add_settings_section( - 'functionalities_pwa_screenshots', - \__( 'Screenshots', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'App screenshots shown in install dialogs and app stores. Provide wide and narrow sizes.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa' - ); - - \add_settings_field( - 'pwa_screenshots', - \__( 'Screenshots', 'functionalities' ), - array( __CLASS__, 'field_pwa_screenshots' ), - 'functionalities_pwa', - 'functionalities_pwa_screenshots' - ); - - // Advanced section. - \add_settings_section( - 'functionalities_pwa_advanced', - \__( 'Advanced', 'functionalities' ), - function() { - echo '

    ' . \esc_html__( 'Advanced PWA features. Only change these if you know what you\'re doing.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa' - ); - - \add_settings_field( - 'pwa_display_override', - \__( 'Display Override', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $checked = ! empty( $o['display_override'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_advanced' - ); - - \add_settings_field( - 'pwa_edge_side_panel', - \__( 'Edge Side Panel', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $checked = ! empty( $o['edge_side_panel'] ) ? 'checked' : ''; - echo ''; - }, - 'functionalities_pwa', - 'functionalities_pwa_advanced' - ); - - \add_settings_field( - 'pwa_launch_handler', - \__( 'Launch Handler', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $handlers = array( - '' => \__( 'Default', 'functionalities' ), - 'focus-existing' => 'focus-existing', - 'navigate-new' => 'navigate-new', - 'navigate-existing' => 'navigate-existing', - ); - echo ''; - echo '

    ' . \esc_html__( 'Controls behavior when the app is already open and a new navigation is triggered.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_advanced' - ); - - \add_settings_field( - 'pwa_share_target', - \__( 'Share Target', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - $checked = ! empty( $o['share_target_enabled'] ) ? 'checked' : ''; - echo ''; - echo '
    '; - echo ''; - echo '
    '; - $methods = array( 'GET' => 'GET', 'POST' => 'POST' ); - echo '
    '; - echo ''; - echo '
    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_advanced' - ); - - \add_settings_field( - 'pwa_advanced_manifest', - \__( 'Custom Manifest JSON', 'functionalities' ), - function() { - $o = self::get_pwa_options(); - echo ''; - echo '

    ' . \esc_html__( 'Raw JSON to merge into the manifest. Must be a valid JSON object.', 'functionalities' ) . '

    '; - }, - 'functionalities_pwa', - 'functionalities_pwa_advanced' - ); - } - - public static function field_nofollow_external() : void { - $opts = self::get_link_management_options(); - $checked = ! empty( $opts['nofollow_external'] ) ? 'checked' : ''; - echo ''; - } - - /** - * Render exceptions field. - * - * @return void - */ - public static function field_exceptions() : void { - $opts = self::get_link_management_options(); - $value = isset( $opts['exceptions'] ) ? (string) $opts['exceptions'] : ''; - echo ''; - echo '

    ' . \esc_html__( 'One per line. Supports full URLs (https://example.com/page), domains (example.com), or partial matches (e.g., /partner/).', 'functionalities' ) . '

    '; - } - - /** - * Render section description for link management. - * - * @return void - */ - public static function section_link_management() : void { - echo '

    ' . \esc_html__( 'Control how external and internal links are handled across your site.', 'functionalities' ) . '

    '; - - // Get module docs. - $docs = Module_Docs::get( 'link-management' ); - - // Render documentation accordions. - echo '
    '; - - if ( ! empty( $docs['features'] ) ) { - $list = '
      '; - foreach ( $docs['features'] as $feature ) { - $list .= '
    • ' . \esc_html( $feature ) . '
    • '; - } - $list .= '
    '; - Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); - } - - if ( ! empty( $docs['hooks'] ) ) { - $hooks_html = '
    '; - foreach ( $docs['hooks'] as $hook ) { - $hooks_html .= '
    ' . \esc_html( $hook['name'] ) . '
    '; - $hooks_html .= '
    ' . \esc_html( $hook['description'] ) . '
    '; - } - $hooks_html .= '
    '; - Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); - } - - echo '
    '; - } - - /** - * Render open external links in new tab field. - * - * @return void - */ - public static function field_open_external_new_tab() : void { - $opts = self::get_link_management_options(); - $checked = ! empty( $opts['open_external_new_tab'] ) ? 'checked' : ''; - echo ''; - } +class Admin extends Module_Controller { /** - * Render open internal links in new tab field. + * Initialize admin controllers. * * @return void */ - public static function field_open_internal_new_tab() : void { - $opts = self::get_link_management_options(); - $checked = ! empty( $opts['open_internal_new_tab'] ) ? 'checked' : ''; - echo ''; - } - - /** - * Render internal new tab exceptions field. - * - * @return void - */ - public static function field_internal_new_tab_exceptions() : void { - $opts = self::get_link_management_options(); - $val = isset( $opts['internal_new_tab_exceptions'] ) ? (string) $opts['internal_new_tab_exceptions'] : ''; - echo ''; - echo '

    ' . \esc_html__( 'One domain per line. Matching hosts will NOT be forced to open in a new tab when internal option is enabled.', 'functionalities' ) . '

    '; - } - - /** - * Render JSON preset URL field. - * - * @return void - */ - public static function field_json_preset_url() : void { - $opts = self::get_link_management_options(); - $val = isset( $opts['json_preset_url'] ) ? (string) $opts['json_preset_url'] : ''; - - // Check for theme exception-urls.json. - $theme_json_path = \get_stylesheet_directory() . '/exception-urls.json'; - $theme_json_exists = file_exists( $theme_json_path ); - $parent_json_path = \get_template_directory() . '/exception-urls.json'; - $parent_json_exists = \get_stylesheet_directory() !== \get_template_directory() && file_exists( $parent_json_path ); - ?> -
    -
    - - - -
    - -

    - -

    -

    - -

    - - -
    - - -

    - get( 'Name' ) ) - ); - ?> -
    -

    - - -

    - get( 'Name' ) ) - ); - ?> -
    -

    - -
    - -

    - - -

    - - -
    - -

    - -

    -

    - -

    -
    - -

    - -

    -
    - - - - - - - - -

    - -

    - -
    -

    - -
    - - - -
    -

    -
    <?php
    -/**
    - * Add trusted domains to nofollow exceptions.
    - * Links to these domains will NOT have nofollow added.
    - */
    -add_filter( 'functionalities_exception_domains', function( $domains ) {
    -    // Add your trusted domains here
    -    $trusted_domains = array(
    -        'trusted-partner.com',
    -        'another-trusted-site.org',
    -        'your-other-website.net',
    -    );
    -
    -    return array_merge( $domains, $trusted_domains );
    -} );
    - -
    -
    - -
    - - - -
    -

    -
    <?php
    -/**
    - * Add specific URLs to nofollow exceptions.
    - * These specific URLs will NOT have nofollow added.
    - */
    -add_filter( 'functionalities_exception_urls', function( $urls ) {
    -    // Add specific URLs or patterns
    -    $trusted_urls = array(
    -        'https://example.com/specific-page',
    -        'https://partner.com/affiliate/*',
    -        'https://trusted.org/blog/*',
    -    );
    -
    -    return array_merge( $urls, $trusted_urls );
    -} );
    - -
    -
    - -
    - - - -
    -

    -
    <?php
    -/**
    - * Dynamically add exception domains based on conditions.
    - * Example: Trust all domains for administrators.
    - */
    -add_filter( 'functionalities_exception_domains', function( $domains ) {
    -    // Skip nofollow for administrators
    -    if ( current_user_can( 'manage_options' ) ) {
    -        // Return wildcard to match all domains
    -        $domains[] = '*';
    -    }
    -
    -    // Add domains from a custom option
    -    $custom_domains = get_option( 'my_trusted_domains', array() );
    -    if ( is_array( $custom_domains ) ) {
    -        $domains = array_merge( $domains, $custom_domains );
    -    }
    -
    -    // Add sponsor domains on specific post types
    -    if ( is_singular( 'sponsored_post' ) ) {
    -        $domains[] = 'sponsor-website.com';
    -    }
    -
    -    return $domains;
    -} );
    - -
    -
    - -
    - - - -
    -

    -
    <?php
    -/**
    - * Use a custom JSON file path for exception URLs.
    - */
    -add_filter( 'functionalities_json_preset_path', function( $default_path ) {
    -    // Use a JSON file from your theme
    -    $theme_json = get_stylesheet_directory() . '/config/exceptions.json';
    -
    -    if ( file_exists( $theme_json ) ) {
    -        return $theme_json;
    -    }
    -
    -    // Or use an external URL (use with caution!)
    -    // return 'https://your-cdn.com/exception-urls.json';
    -
    -    return $default_path;
    -} );
    - -
    -
    - - -
    - - - -
    -

    - -

    - - -
    - -
    - ' . \esc_html__( 'Remove Gutenberg block-specific CSS classes from your frontend HTML for cleaner markup.', 'functionalities' ) . '

    '; - - // Get module docs. - $docs = Module_Docs::get( 'block-cleanup' ); - - // Render documentation accordions. - echo '
    '; - - if ( ! empty( $docs['features'] ) ) { - $list = '
      '; - foreach ( $docs['features'] as $feature ) { - $list .= '
    • ' . \esc_html( $feature ) . '
    • '; - } - $list .= '
    '; - Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); - } - - // Classes removed info. - $classes_html = '
      '; - $classes_html .= '
    • wp-block-heading → ' . \esc_html__( 'from h1-h6 elements', 'functionalities' ) . '
    • '; - $classes_html .= '
    • wp-block-list → ' . \esc_html__( 'from ul/ol elements', 'functionalities' ) . '
    • '; - $classes_html .= '
    • wp-block-image → ' . \esc_html__( 'from figure/div elements', 'functionalities' ) . '
    • '; - $classes_html .= '
    '; - Admin_UI::render_docs_section( \__( 'Classes Removed', 'functionalities' ), $classes_html, 'usage' ); - - if ( ! empty( $docs['hooks'] ) ) { - $hooks_html = '
    '; - foreach ( $docs['hooks'] as $hook ) { - $hooks_html .= '
    ' . \esc_html( $hook['name'] ) . '
    '; - $hooks_html .= '
    ' . \esc_html( $hook['description'] ) . '
    '; - } - $hooks_html .= '
    '; - Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); - } - - echo '
    '; - } - - // Block Cleanup: fields & helpers - public static function field_bc_remove_heading() : void { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_heading_block_class'] ) ? 'checked' : ''; - echo ''; - } - public static function field_bc_remove_list() : void { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_list_block_class'] ) ? 'checked' : ''; - echo ''; - } - public static function field_bc_remove_image() : void { - $opts = self::get_block_cleanup_options(); - $checked = ! empty( $opts['remove_image_block_class'] ) ? 'checked' : ''; - echo ''; - } - - // Editor Links: fields & helpers - public static function field_el_enable() : void { - $opts = self::get_editor_links_options(); - $checked = ! empty( $opts['enable_limit'] ) ? 'checked' : ''; - echo ''; - } - public static function field_el_post_types() : void { - $opts = self::get_editor_links_options(); - $selected = isset( $opts['post_types'] ) && is_array( $opts['post_types'] ) ? $opts['post_types'] : []; - $pts = get_post_types( [ 'public' => true ], 'objects' ); - echo '
    '; - foreach ( $pts as $name => $obj ) { - $is_checked = in_array( $name, $selected, true ) ? 'checked' : ''; - $label = sprintf( '%s (%s)', $obj->labels->singular_name ?? $name, $name ); - echo ''; - } - echo '
    '; - } - - public static function field_components_items() : void { - $o = self::get_components_options(); - $items = isset( $o['items'] ) && is_array( $o['items'] ) ? $o['items'] : []; - $per_page = 24; - $total_items = count( $items ); - $total_pages = (int) max( 1, ceil( $total_items / $per_page ) ); - ?> - - -
    -
    - - - - -
    - -
    - -
    -
    -
    - -
    -
    -
    -
    -

    -
    - -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - -
    - - 1 ) : ?> -
    - - - - - - - - -
    - - - - -
    - - - -

    - -

    - ' . esc_html( $label ) . ''; - }, - 'functionalities_misc', - 'functionalities_misc_section' - ); - } - - public static function field_fonts_items() : void { - $o = self::get_fonts_options(); - $items = isset( $o['items'] ) && is_array( $o['items'] ) ? $o['items'] : []; - $total_items = count( $items ); - - // Enqueue media uploader - \wp_enqueue_media(); - ?> - - -
    -
    - - - - -
    - -
    - -
    -
    🔤
    -

    -

    -
    - - - -
    -
    -
    -

    -
    - - - - - - - -
    -
    -
    - - -
    -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - > - -
    -
    - > - -
    -
    -
    - -
    - - -
    -
    -
    - -
    - - -
    -
    -
    - - -

    - -

    -
    -
    - - -
    -
    -
    - -
    -
    - - - - - - - - -

    - -

    - -
    - -

    -
    - -
    - -

    - - - - - - - - - - - - - - - - -
    - - - ' . \esc_html__( 'Add copyright metadata, Dublin Core (DCMI) tags, and per-post licensing options. Works standalone or integrates with major SEO plugins.', 'functionalities' ) . '

    '; - - // Get module docs. - $docs = Module_Docs::get( 'meta' ); - - // Render documentation accordions. - echo '
    '; - - if ( ! empty( $docs['features'] ) ) { - $list = '
      '; - foreach ( $docs['features'] as $feature ) { - $list .= '
    • ' . \esc_html( $feature ) . '
    • '; - } - $list .= '
    '; - Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); - } - - // Schema.org Support (dynamic based on detected plugin). - $plugin_names = array( - 'rank-math' => 'Rank Math', - 'yoast' => 'Yoast SEO', - 'seo-framework' => 'The SEO Framework', - 'seopress' => 'SEOPress', - 'aioseo' => 'All in One SEO', - ); - - if ( $detected !== 'none' ) { - $schema_content = '

    ✓ ' . \esc_html__( 'Detected:', 'functionalities' ) . ' ' . \esc_html( $plugin_names[ $detected ] ?? $detected ) . '

    '; - $schema_content .= '

    ' . \esc_html__( 'Copyright data will be added to your SEO plugin\'s existing schema output.', 'functionalities' ) . '

    '; - } else { - $schema_content = '

    ✓ ' . \esc_html__( 'Standalone Mode', 'functionalities' ) . '

    '; - $schema_content .= '

    ' . \esc_html__( 'No SEO plugin detected. Complete Article schema with copyright will be output independently.', 'functionalities' ) . '

    '; - } - Admin_UI::render_docs_section( \__( 'Schema.org Support', 'functionalities' ), $schema_content, 'developer', true ); - - // Compatible plugins. - $plugins_html = '
      '; - $plugins_html .= '
    • Rank Math
    • '; - $plugins_html .= '
    • Yoast SEO
    • '; - $plugins_html .= '
    • The SEO Framework
    • '; - $plugins_html .= '
    • SEOPress
    • '; - $plugins_html .= '
    • All in One SEO
    • '; - $plugins_html .= '
    • ' . \esc_html__( 'or Standalone', 'functionalities' ) . '
    • '; - $plugins_html .= '
    '; - Admin_UI::render_docs_section( \__( 'Compatible SEO Plugins', 'functionalities' ), $plugins_html, 'usage' ); - - if ( ! empty( $docs['hooks'] ) ) { - $hooks_html = '
    '; - foreach ( $docs['hooks'] as $hook ) { - $hooks_html .= '
    ' . \esc_html( $hook['name'] ) . '
    '; - $hooks_html .= '
    ' . \esc_html( $hook['description'] ) . '
    '; - } - $hooks_html .= '
    '; - Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); - } - - echo '
    '; - } - - /** - * Render section description for content regression detection. - * - * @return void - */ - public static function section_content_regression() : void { - echo '

    ' . \esc_html__( 'Detect structural regressions when posts are updated. This module compares each post against its own historical baseline.', 'functionalities' ) . '

    '; - - // Get module docs. - $docs = Module_Docs::get( 'content-regression' ); - - // Render documentation accordions. - echo '
    '; - - if ( ! empty( $docs['features'] ) ) { - $list = '
      '; - foreach ( $docs['features'] as $feature ) { - $list .= '
    • ' . \esc_html( $feature ) . '
    • '; - } - $list .= '
    '; - Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); - } - - if ( ! empty( $docs['usage'] ) ) { - Admin_UI::render_docs_section( \__( 'Philosophy', 'functionalities' ), '

    ' . \esc_html( $docs['usage'] ) . '

    ', 'usage' ); - } - - if ( ! empty( $docs['hooks'] ) ) { - $hooks_html = '
    '; - foreach ( $docs['hooks'] as $hook ) { - $hooks_html .= '
    ' . \esc_html( $hook['name'] ) . '
    '; - $hooks_html .= '
    ' . \esc_html( $hook['description'] ) . '
    '; - } - $hooks_html .= '
    '; - Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); - } - - echo '
    '; - } - - /** - * Assumption detection section callback. - * - * @return void - */ - public static function section_assumption_detection() : void { - echo '

    ' . \esc_html__( 'Detects when technical assumptions stop being true. This module notices changes, not problems.', 'functionalities' ) . '

    '; - - // Get module docs. - $docs = Module_Docs::get( 'assumption-detection' ); - - // Render documentation accordions. - if ( ! empty( $docs['features'] ) ) { - $list = '
      '; - foreach ( $docs['features'] as $feature ) { - $list .= '
    • ' . \esc_html( $feature ) . '
    • '; - } - $list .= '
    '; - Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); - } - - if ( ! empty( $docs['usage'] ) ) { - Admin_UI::render_docs_section( \__( 'How to Use', 'functionalities' ), '

    ' . \esc_html( $docs['usage'] ) . '

    ', 'usage' ); - } - - if ( ! empty( $docs['hooks'] ) ) { - $hooks_html = '
    '; - foreach ( $docs['hooks'] as $hook ) { - $hooks_html .= '
    ' . \esc_html( $hook['name'] ) . '
    '; - $hooks_html .= '
    ' . \esc_html( $hook['description'] ) . '
    '; - } - $hooks_html .= '
    '; - Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); - } - } - - /** - * Detected assumptions field callback. - * - * @return void - */ - public static function field_detected_assumptions() : void { - $assumptions = \Functionalities\Features\Assumption_Detection::get_detected_assumptions(); - $ignored = \Functionalities\Features\Assumption_Detection::get_ignored_assumptions(); - - // Filter out expired ignored items and already-ignored assumptions. - $active_warnings = array(); - foreach ( $assumptions as $assumption ) { - $hash = self::generate_warning_hash( $assumption ); - // Skip if ignored and not expired. - if ( isset( $ignored[ $hash ] ) && $ignored[ $hash ]['expires'] > time() ) { - continue; - } - $assumption['_hash'] = $hash; - $active_warnings[] = $assumption; - } - - if ( empty( $active_warnings ) ) { - echo '
    '; - echo ''; - echo '' . \esc_html__( 'No assumption changes detected. All monitored items are consistent.', 'functionalities' ) . ''; - echo '
    '; - return; - } - - echo '
    '; - - foreach ( $active_warnings as $assumption ) { - $hash = $assumption['_hash']; - $warning_type = $assumption['type'] ?? 'unknown'; - $type_class = 'warning'; - $icon = 'dashicons-warning'; - - echo '
    '; - echo '
    '; - echo ''; - echo '
    '; - echo '

    ' . \esc_html( $assumption['message'] ?? '' ) . '

    '; - - // Show location (where) if available. - if ( ! empty( $assumption['location'] ) ) { - echo '

    '; - echo '' . \esc_html__( 'Where:', 'functionalities' ) . ' '; - echo wp_kses( $assumption['location'], array( 'code' => array(), 'strong' => array() ) ); - echo '

    '; - } - - // Show reason (why) if available. - if ( ! empty( $assumption['reason'] ) ) { - echo '

    '; - echo '' . \esc_html__( 'Why it matters:', 'functionalities' ) . ' '; - echo \esc_html( $assumption['reason'] ); - echo '

    '; - } - - // Show type badge. - echo '' . \esc_html( str_replace( '_', ' ', ucfirst( $warning_type ) ) ) . ''; - - if ( ! empty( $assumption['detected'] ) ) { - $time_ago = \human_time_diff( $assumption['detected'], \time() ); - /* translators: %s: human-readable time difference */ - echo '' . \sprintf( \esc_html__( 'Detected %s ago', 'functionalities' ), \esc_html( $time_ago ) ) . ''; - } - - echo '
    '; - - // Actions. - echo '
    '; - echo ''; - echo ''; - echo ''; - echo '
    '; - - echo '
    '; - } - - echo '
    '; - - // Inline script for AJAX actions. - $nonce = \wp_create_nonce( 'functionalities_assumptions' ); - ?> - - false ) ); - - // Handle enable/disable toggle. - if ( isset( $_POST['functionalities_task_manager_toggle'] ) && \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'functionalities_task_manager_toggle' ) ) { - $tm_opts['enabled'] = ! empty( $_POST['enabled'] ); - \update_option( 'functionalities_task_manager', $tm_opts ); - echo '

    ' . \esc_html__( 'Settings saved.', 'functionalities' ) . '

    '; - } - ?> -
    -

    - - -

    - - - -
    - - - -
    - - - - - - -
    - - -
    -

    - - -

    -

    -
    -
    - -
    - -

    -
    -
    -
    - -
    - -

    -
    -
    -
    - -
    - -

    -
    -
    -
    - -
    - -

    -
    -
    -
    - -
    - -

    -
    -
    -
    - -
    - -

    -
    -
    -
    -

    - - ' . \esc_html( WP_CONTENT_DIR . '/functionalities/tasks/' ) . '' - ); - ?> -

    -
    - -
    - - - -
    - - -
    - -

    -

    -
    - -
    - $project ) : - $stats = \Functionalities\Features\Task_Manager::get_stats( $project ); - ?> -
    -

    - - -

    -
    - -
    -
    -
    -
    - -
    - - -
    - -
    - - - - - -
    -
    - -
    - - - -
    -
    -

    -

    - -
    - - -
    -
    -
    - - -
    -
    -

    -

    - -
    - - -
    -
    -
    - - - -
    -
    -

    - - - () - -

    -
    -
    -
    -
    -
    -
    - - -
    - - - -
    -
    - -
    -
    -
    - - -
    - - - -
    - -
    -
    - -
    -
    -
    - -
    - - -
    - -
    - -
    -

    -
    - - -
    -
    - -
    - > -
    -

    -
    - - - ! - - - - # - -
    - -
    - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - !1 - 0 -
    -
    -
    -
    -
    - !2 - 0 -
    -
    -
    -
    -
    - !3 - 0 -
    -
    -
    -
    -
    - - 0 -
    -
    -
    -
    - - -
    -
    -

    - -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    -

    -

    - -
    - - -
    -
    -
    - - - false ) ); - - // Handle enable/disable toggle. - if ( isset( $_POST['functionalities_redirect_manager_toggle'] ) && \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'functionalities_redirect_manager_toggle' ) ) { - $rm_opts['enabled'] = ! empty( $_POST['enabled'] ); - \update_option( 'functionalities_redirect_manager', $rm_opts ); - echo '

    ' . \esc_html__( 'Settings saved.', 'functionalities' ) . '

    '; - } - ?> -
    -

    - - -

    - - - -
    - - - -
    - -
    -

    -

    -
    -
    - -
    -
    -
    - -
    -
    -
    - -
    -
    -
    -
    - -
    -

    -
    - - - - -
    -

    -
    - -
    -
    -
    - - -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    > - -
    -
    - -
    - - -
    - - -
    - - -

    ' . \esc_html__( 'Settings saved.', 'functionalities' ) . '

    '; - } - ?> -
    -

    - - -

    - - - - -
    - - - -
    - - -
    - '; - foreach ( $docs['features'] as $feature ) { - $list .= '
  • ' . \esc_html( $feature ) . '
  • '; - } - $list .= '
'; - Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); - } - if ( ! empty( $docs['usage'] ) ) { - Admin_UI::render_docs_section( \__( 'How to Use', 'functionalities' ), '

' . \esc_html( $docs['usage'] ) . '

', 'usage' ); - } - if ( ! empty( $docs['hooks'] ) ) { - $hooks_html = '
'; - foreach ( $docs['hooks'] as $hook ) { - $hooks_html .= '
' . \esc_html( $hook['name'] ) . '
'; - $hooks_html .= '
' . \esc_html( $hook['description'] ) . '
'; - } - $hooks_html .= '
'; - Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); - } - ?> - - - -
-

-
- - -

-
-
- - -
-
- - -

-
-
- -
-
- - -
-
- - -

()

- - -
- -

-
- -
- $icon ) : ?> -
-
- -
-
-

-

-
-
- - -
-
- -
- - - - - - - true, - 'class' => true, - 'style' => true, - 'fill' => true, - 'fill-opacity' => true, - 'fill-rule' => true, - 'stroke' => true, - 'stroke-width' => true, - 'stroke-linecap' => true, - 'stroke-linejoin' => true, - 'stroke-dasharray' => true, - 'stroke-dashoffset' => true, - 'stroke-opacity' => true, - 'opacity' => true, - 'transform' => true, - 'clip-path' => true, - 'clip-rule' => true, - 'mask' => true, - ); - - return array( - 'svg' => array_merge( $common_attrs, array( - 'xmlns' => true, - 'xmlns:xlink' => true, - 'viewbox' => true, - 'width' => true, - 'height' => true, - 'aria-hidden' => true, - 'role' => true, - 'focusable' => true, - 'preserveaspectratio' => true, - 'version' => true, - 'xml:space' => true, - 'enable-background' => true, - ) ), - 'g' => $common_attrs, - 'path' => array_merge( $common_attrs, array( 'd' => true ) ), - 'circle' => array_merge( $common_attrs, array( - 'cx' => true, - 'cy' => true, - 'r' => true, - ) ), - 'ellipse' => array_merge( $common_attrs, array( - 'cx' => true, - 'cy' => true, - 'rx' => true, - 'ry' => true, - ) ), - 'rect' => array_merge( $common_attrs, array( - 'x' => true, - 'y' => true, - 'width' => true, - 'height' => true, - 'rx' => true, - 'ry' => true, - ) ), - 'line' => array_merge( $common_attrs, array( - 'x1' => true, - 'y1' => true, - 'x2' => true, - 'y2' => true, - ) ), - 'polyline' => array_merge( $common_attrs, array( 'points' => true ) ), - 'polygon' => array_merge( $common_attrs, array( 'points' => true ) ), - 'defs' => array( 'id' => true ), - 'clippath' => array( 'id' => true ), - 'mask' => array( 'id' => true ), - 'use' => array( - 'xlink:href' => true, - 'href' => true, - 'x' => true, - 'y' => true, - 'width' => true, - 'height' => true, - ), - 'symbol' => array( - 'id' => true, - 'viewbox' => true, - 'preserveaspectratio' => true, - ), - 'title' => array(), - 'desc' => array(), - 'lineargradient' => array( - 'id' => true, - 'gradientunits' => true, - 'gradienttransform' => true, - 'spreadmethod' => true, - 'x1' => true, - 'y1' => true, - 'x2' => true, - 'y2' => true, - ), - 'radialgradient' => array( - 'id' => true, - 'gradientunits' => true, - 'gradienttransform' => true, - 'spreadmethod' => true, - 'cx' => true, - 'cy' => true, - 'r' => true, - 'fx' => true, - 'fy' => true, - ), - 'stop' => array( - 'offset' => true, - 'stop-color' => true, - 'stop-opacity' => true, - 'style' => true, - ), - ); - } - - // ------------------------------------------------------------------------- - // PWA Field Renderers - // ------------------------------------------------------------------------- - - /** - * Render a media upload field. - * - * @param string $name Input name attribute. - * @param string $value Current URL value. - * @param string $desc Description text. - * @return void - */ - private static function render_media_field( string $name, string $value, string $desc = '' ) : void { - $id = 'func-media-' . \sanitize_key( str_replace( array( '[', ']' ), '-', $name ) ); - echo '
'; - echo ''; - echo ' '; - if ( $value ) { - echo '
'; - } - if ( $desc ) { - echo '

' . \esc_html( $desc ) . '

'; - } - echo '
'; - } - - /** - * Render snippets repeater field for a given location. - * - * @since 1.4.0 - * - * @param string $location Location key: header, body_open, or footer. - * @param string $hook_name WordPress hook name for description. - * @return void - */ - public static function field_snippets_repeater( string $location, string $hook_name ) : void { - $o = self::get_snippets_options(); - $items = ! empty( $o[ $location ] ) && \is_array( $o[ $location ] ) ? $o[ $location ] : array(); - $cid = 'func-snippets-' . $location; - - echo '
'; - $i = 0; - foreach ( $items as $item ) { - self::render_snippet_row( $location, $i, $item ); - $i++; - } - echo '
'; - - echo ''; - echo '

' . \sprintf( - /* translators: %s: WordPress hook name */ - \esc_html__( 'Output in %s. Each snippet can be individually toggled.', 'functionalities' ), - '' . \esc_html( $hook_name ) . '' - ) . '

'; - - // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inline JS for repeater template, encoded via wp_json_encode. - echo ''; - } - - /** - * Render a single snippet repeater row. - * - * @since 1.4.0 - * - * @param string $location Location key. - * @param int $index Row index. - * @param array $data Snippet data (label, code, enabled). - * @return void - */ - private static function render_snippet_row( string $location, int $index, array $data ) : void { - $label = $data['label'] ?? ''; - $code = $data['code'] ?? ''; - $enabled = ! empty( $data['enabled'] ); - $prefix = 'functionalities_snippets[' . $location . '][' . $index . ']'; - $has_code = '' !== $code; - - echo '
'; - - // Header bar — always visible. - echo '
'; - echo ''; - echo ''; - if ( $has_code ) { - echo '' . \esc_html( self::snippet_type_badge( $code ) ) . ''; - } - echo ''; - echo ''; - echo '
'; - - // Body — collapsible. - echo '
'; - echo ''; - echo '
'; - - echo '
'; - } - - /** - * Detect snippet type from code for badge display. - * - * @since 1.4.0 - * - * @param string $code Snippet code. - * @return string Badge label. - */ - private static function snippet_type_badge( string $code ) : string { - $code_lower = strtolower( ltrim( $code ) ); - if ( 0 === strpos( $code_lower, ' true ) ); - $html = ob_get_clean(); - return str_replace( (string) $sentinel, $index, $html ); - } - - /** - * Render PWA shortcuts repeater field. - * - * @return void - */ - public static function field_pwa_shortcuts() : void { - $o = self::get_pwa_options(); - $shortcuts = ! empty( $o['shortcuts'] ) ? $o['shortcuts'] : array(); - echo '
'; - $i = 0; - foreach ( $shortcuts as $sc ) { - self::render_shortcut_row( $i, $sc ); - $i++; - } - echo '
'; - echo ''; - echo '

' . \esc_html__( 'Maximum 4 shortcuts. Each needs a name and URL.', 'functionalities' ) . '

'; - echo ''; - } - - /** - * Render a single shortcut row. - * - * @param int $index Row index. - * @param array $data Shortcut data. - * @return void - */ - private static function render_shortcut_row( int $index, array $data ) : void { - $name = \esc_attr( $data['name'] ?? '' ); - $url = \esc_attr( $data['url'] ?? '' ); - $desc = \esc_attr( $data['description'] ?? '' ); - $icon = \esc_attr( $data['icon'] ?? '' ); - $prefix = 'functionalities_pwa[shortcuts][' . $index . ']'; - echo '
'; - echo '
'; - echo ''; - echo ''; - echo ''; - echo ''; - echo '
'; - echo ''; - echo '
'; - } - - /** - * Get shortcut row HTML template for JS. - * - * @param string $index Placeholder index. - * @return string HTML template. - */ - private static function get_shortcut_row_html( string $index ) : string { - ob_start(); - self::render_shortcut_row( (int) $index, array() ); - $html = ob_get_clean(); - return str_replace( (string) (int) $index, $index, $html ); - } - - /** - * Render PWA screenshots repeater field. - * - * @return void - */ - public static function field_pwa_screenshots() : void { - $o = self::get_pwa_options(); - $screenshots = ! empty( $o['screenshots'] ) ? $o['screenshots'] : array(); - echo '
'; - $i = 0; - foreach ( $screenshots as $ss ) { - self::render_screenshot_row( $i, $ss ); - $i++; - } - echo '
'; - echo ''; - echo ''; - } - - /** - * Render a single screenshot row. - * - * @param int $index Row index. - * @param array $data Screenshot data. - * @return void - */ - private static function render_screenshot_row( int $index, array $data ) : void { - $src = \esc_attr( $data['src'] ?? '' ); - $sizes = \esc_attr( $data['sizes'] ?? '' ); - $label = \esc_attr( $data['label'] ?? '' ); - $form = $data['form_factor'] ?? 'wide'; - $prefix = 'functionalities_pwa[screenshots][' . $index . ']'; - echo '
'; - echo '
'; - echo ''; - echo ''; - echo ''; - echo ''; - echo '
'; - echo ''; - echo '
'; - } - - /** - * Get screenshot row HTML template for JS. - * - * @param string $index Placeholder index. - * @return string HTML template. - */ - private static function get_screenshot_row_html( string $index ) : string { - ob_start(); - self::render_screenshot_row( (int) $index, array() ); - $html = ob_get_clean(); - return str_replace( (string) (int) $index, $index, $html ); + public static function init(): void { + parent::init(); + Settings_Portability_Controller::init(); } } diff --git a/includes/admin/class-module-controller.php b/includes/admin/class-module-controller.php new file mode 100644 index 0000000..663c1fc --- /dev/null +++ b/includes/admin/class-module-controller.php @@ -0,0 +1,7232 @@ + $module ) { + if ( in_array( $slug, $skip_submenus, true ) ) { + continue; + } + + \add_submenu_page( + $parent_slug, + $module['title'] . ' ‹ ' . \__( 'Functionalities', 'functionalities' ), + $module['title'], + 'manage_options', + 'functionalities-' . $slug, + array( __CLASS__, 'render_main_page' ) + ); + } + } + + /** + * Enqueue admin assets. + * + * @param string $hook Current admin page hook. + * @return void + */ + public static function enqueue_admin_assets( $hook ): void { + if ( strpos( $hook, 'functionalities' ) === false ) { + return; + } + + $deps = array( 'jquery' ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Page detection doesn't require nonce. + $page = isset( $_GET['page'] ) ? \sanitize_key( $_GET['page'] ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Module detection doesn't require nonce. + $module = isset( $_GET['module'] ) ? \sanitize_key( $_GET['module'] ) : ''; + + if ( 'task-manager' === $module || 'functionalities-task-manager' === $page ) { + $deps[] = 'jquery-ui-sortable'; + } + if ( 'redirect-manager' === $module || 'functionalities-redirect-manager' === $page ) { + \wp_enqueue_script( + 'functionalities-admin-redirects', + FUNCTIONALITIES_URL . 'assets/js/admin-redirects.js', + array( 'jquery' ), + FUNCTIONALITIES_VERSION, + true + ); + \wp_localize_script( + 'functionalities-admin-redirects', + 'functionalitiesRedirectAdmin', + array( + 'ajaxUrl' => \admin_url( 'admin-ajax.php' ), + 'nonce' => \wp_create_nonce( 'functionalities_redirect_manager' ), + 'bothRequired' => \__( 'Both URLs are required.', 'functionalities' ), + 'deletePrompt' => \__( 'Delete this redirect?', 'functionalities' ), + 'purgePrompt' => \__( 'Purge the retained 404 log?', 'functionalities' ), + ) + ); + } + + if ( 'pwa' === $module ) { + \wp_enqueue_media(); + \wp_enqueue_style( 'wp-color-picker' ); + $deps[] = 'wp-color-picker'; + } + + \wp_enqueue_style( + 'functionalities-admin', + FUNCTIONALITIES_URL . 'assets/css/admin.css', + array( 'dashicons' ), + FUNCTIONALITIES_VERSION + ); + + \wp_enqueue_script( + 'functionalities-admin', + FUNCTIONALITIES_URL . 'assets/js/admin.js', + $deps, + FUNCTIONALITIES_VERSION, + true + ); + + // Localize script with AJAX data. + \wp_localize_script( + 'functionalities-admin', + 'functionalitiesAdmin', + array( + 'ajaxUrl' => \admin_url( 'admin-ajax.php' ), + 'runDetectionNonce' => \wp_create_nonce( 'functionalities_run_detection' ), + 'runningText' => \__( 'Running...', 'functionalities' ), + 'runDetectionText' => \__( 'Run Detection Now', 'functionalities' ), + ) + ); + } + + /** + * Render main admin page with module navigation. + * + * @return void + */ + public static function render_main_page(): void { + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_die( \esc_html__( 'Insufficient permissions', 'functionalities' ) ); + } + + // Get current module from URL parameter or page slug. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Page/module detection doesn't require nonce. + $current_module = isset( $_GET['module'] ) ? \sanitize_key( $_GET['module'] ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Page detection doesn't require nonce. + $page = isset( $_GET['page'] ) ? \sanitize_key( $_GET['page'] ) : ''; + + if ( empty( $current_module ) && strpos( $page, 'functionalities-' ) === 0 ) { + $current_module = str_replace( 'functionalities-', '', $page ); + } + + // If no module or invalid module, show dashboard. + if ( empty( $current_module ) || ! isset( self::$modules[ $current_module ] ) ) { + self::render_dashboard(); + return; + } + + // Render specific module. + self::render_module( $current_module ); + } + + /** + * Render dashboard with module cards. + * + * @return void + */ + /** + * Check if a module is enabled. + * + * @param string $slug Module slug (hyphenated). + * @return bool + */ + private static function is_module_enabled( string $slug ): bool { + return \Functionalities\Core\Module_Registry::is_enabled( $slug ); + } + + private static function render_dashboard(): void { + ?> +
+

+

+ +

+ +
+ $module ) : + $is_active = self::is_module_enabled( $slug ); + ?> +
+
+ +

+
+

+
+ + + + + + + + +
+
+ +
+ + + +
+

+ + +

+ + +
+ + +
+

+ + +

+

+ +

+ +

+ functionalities.dev' + ); + ?> +

+
+
+ +
+

+ + +

+ + + +
+ +
+
+ array( __CLASS__, 'sanitize_link_management' ), + 'default' => array( + 'enabled' => false, + 'nofollow_external' => false, + 'exceptions' => '', + 'open_external_new_tab' => false, + 'open_internal_new_tab' => false, + 'internal_new_tab_exceptions' => '', + ), + ) + ); + + \add_settings_section( + 'functionalities_link_management_section', + \__( 'Link Management Settings', 'functionalities' ), + array( __CLASS__, 'section_link_management' ), + 'functionalities_link_management' + ); + + \add_settings_field( + 'enabled', + \__( 'Enable Link Management', 'functionalities' ), + function () { + $o = self::get_link_management_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + + \add_settings_field( + 'nofollow_external', + \__( 'Add nofollow to external links', 'functionalities' ), + array( __CLASS__, 'field_nofollow_external' ), + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + + \add_settings_field( + 'exceptions', + \__( 'Exceptions', 'functionalities' ), + array( __CLASS__, 'field_exceptions' ), + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + + // New tab options. + \add_settings_field( + 'open_external_new_tab', + \__( 'Open external links in new tab', 'functionalities' ), + array( __CLASS__, 'field_open_external_new_tab' ), + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + \add_settings_field( + 'open_internal_new_tab', + \__( 'Open internal links in new tab', 'functionalities' ), + array( __CLASS__, 'field_open_internal_new_tab' ), + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + \add_settings_field( + 'internal_new_tab_exceptions', + \__( 'Internal new-tab exceptions (domains)', 'functionalities' ), + array( __CLASS__, 'field_internal_new_tab_exceptions' ), + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + + // Advanced features. + \add_settings_field( + 'json_preset_url', + \__( 'JSON Preset File Path', 'functionalities' ), + array( __CLASS__, 'field_json_preset_url' ), + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + \add_settings_field( + 'enable_developer_filters', + \__( 'Enable Developer Filters', 'functionalities' ), + array( __CLASS__, 'field_enable_developer_filters' ), + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + \add_settings_field( + 'database_update_tool', + \__( 'Database Update Tool', 'functionalities' ), + array( __CLASS__, 'field_database_update_tool' ), + 'functionalities_link_management', + 'functionalities_link_management_section' + ); + + // Block Cleanup settings + \register_setting( + 'functionalities_block_cleanup', + 'functionalities_block_cleanup', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_block_cleanup' ), + 'default' => array( + 'enabled' => false, + 'remove_heading_block_class' => false, + 'remove_list_block_class' => false, + 'remove_image_block_class' => false, + 'remove_paragraph_block_class' => false, + 'remove_quote_block_class' => false, + 'remove_table_block_class' => false, + 'remove_separator_block_class' => false, + 'remove_group_block_class' => false, + 'remove_columns_block_class' => false, + 'remove_button_block_class' => false, + 'remove_cover_block_class' => false, + 'remove_media_text_block_class' => false, + 'custom_classes_to_remove' => '', + ), + ) + ); + + \add_settings_section( + 'functionalities_block_cleanup_section', + \__( 'Frontend Block Class Cleanup', 'functionalities' ), + array( __CLASS__, 'section_block_cleanup' ), + 'functionalities_block_cleanup' + ); + + \add_settings_field( + 'enabled', + \__( 'Enable Block Cleanup', 'functionalities' ), + function () { + $o = self::get_block_cleanup_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + + \add_settings_field( + 'remove_heading_block_class', + \__( 'Headings', 'functionalities' ), + array( __CLASS__, 'field_bc_remove_heading' ), + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_list_block_class', + \__( 'Lists', 'functionalities' ), + array( __CLASS__, 'field_bc_remove_list' ), + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_image_block_class', + \__( 'Images', 'functionalities' ), + array( __CLASS__, 'field_bc_remove_image' ), + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_paragraph_block_class', + \__( 'Paragraphs', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_paragraph_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_quote_block_class', + \__( 'Quotes', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_quote_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_table_block_class', + \__( 'Tables', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_table_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_separator_block_class', + \__( 'Separators', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_separator_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_group_block_class', + \__( 'Groups', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_group_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_columns_block_class', + \__( 'Columns', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_columns_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_button_block_class', + \__( 'Buttons', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_button_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_cover_block_class', + \__( 'Covers', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_cover_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'remove_media_text_block_class', + \__( 'Media & Text', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_media_text_block_class'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + \add_settings_field( + 'custom_classes_to_remove', + \__( 'Custom Classes', 'functionalities' ), + function () { + $opts = self::get_block_cleanup_options(); + $val = isset( $opts['custom_classes_to_remove'] ) ? $opts['custom_classes_to_remove'] : ''; + echo ''; + echo '

' . \esc_html__( 'Enter additional CSS classes to remove from content output (one per line). Example: my-plugin-class', 'functionalities' ) . '

'; + }, + 'functionalities_block_cleanup', + 'functionalities_block_cleanup_section' + ); + + // Editor Link Suggestions settings + \register_setting( + 'functionalities_editor_links', + 'functionalities_editor_links', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_editor_links' ), + 'default' => array( + 'enabled' => false, + 'enable_limit' => false, + 'post_types' => self::default_editor_link_post_types(), + ), + ) + ); + + \add_settings_section( + 'functionalities_editor_links_section', + \__( 'Editor Link Suggestions', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Control which post types appear in the block editor link search suggestions.', 'functionalities' ) . '

'; + + echo '
'; + echo '

' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

'; + echo '
    '; + echo '
  • ' . \esc_html__( 'Limits link search results to specific post types when inserting links in Gutenberg', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Reduces clutter by hiding unwanted content types from search results', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Works with posts, pages, and custom post types', 'functionalities' ) . '
  • '; + echo '
'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'How to Use', 'functionalities' ) . '

'; + echo '

' . \esc_html__( 'Enable limitation, then check only the post types you want to appear when searching for links in the editor. Unchecked post types will be hidden from link search results.', 'functionalities' ) . '

'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'For Developers', 'functionalities' ) . '

'; + echo '

'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_editor_links_enabled — ' . \esc_html__( 'toggle feature', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_editor_links_post_types — ' . \esc_html__( 'modify allowed post types', 'functionalities' ); + echo '

'; + echo '
'; + }, + 'functionalities_editor_links' + ); + + \add_settings_field( + 'enabled', + \__( 'Enable Editor Link Suggestions', 'functionalities' ), + function () { + $o = self::get_editor_links_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_editor_links', + 'functionalities_editor_links_section' + ); + + \add_settings_field( + 'enable_limit', + \__( 'Enable limitation', 'functionalities' ), + array( __CLASS__, 'field_el_enable' ), + 'functionalities_editor_links', + 'functionalities_editor_links_section' + ); + \add_settings_field( + 'post_types', + \__( 'Allowed post types', 'functionalities' ), + array( __CLASS__, 'field_el_post_types' ), + 'functionalities_editor_links', + 'functionalities_editor_links_section' + ); + + // Header & Footer (Snippets) settings + \register_setting( + 'functionalities_snippets', + 'functionalities_snippets', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_snippets' ), + 'default' => array( + 'enabled' => false, + 'enable_ga4' => false, + 'ga4_id' => '', + 'header' => array(), + 'body_open' => array(), + 'footer' => array(), + ), + ) + ); + + \add_settings_section( + 'functionalities_snippets_section', + \__( 'Header & Footer Code', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Insert custom code snippets into your site header and footer without editing theme files.', 'functionalities' ) . '

'; + + echo '
'; + echo '

' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

'; + echo '
    '; + echo '
  • ' . \esc_html__( 'Native Google Analytics 4 integration - just enter your Measurement ID', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Multiple snippets per location — each independently toggleable', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Custom code for meta tags, scripts, styles, and tracking codes', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Automatically skips admin pages, feeds, and REST API requests', 'functionalities' ) . '
  • '; + echo '
'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'Allowed Tags', 'functionalities' ) . '

'; + echo '

'; + echo '<script>, <style>, <link>, <meta>, <noscript>'; + echo '

'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'For Developers', 'functionalities' ) . '

'; + echo '

'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_snippets_output_enabled — ' . \esc_html__( 'disable on specific pages', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_snippets_ga4_enabled — ' . \esc_html__( 'control GA4 per user/page', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_snippets_header_code — ' . \esc_html__( 'modify header code', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_snippets_footer_code — ' . \esc_html__( 'modify footer code', 'functionalities' ) . '
'; + echo \esc_html__( 'Actions:', 'functionalities' ) . ' functionalities_before/after_header/footer_snippets'; + echo '

'; + echo '
'; + }, + 'functionalities_snippets' + ); + + \add_settings_field( + 'enabled', + \__( 'Enable Header & Footer', 'functionalities' ), + function () { + $o = self::get_snippets_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_snippets', + 'functionalities_snippets_section' + ); + + \add_settings_field( + 'enable_ga4', + \__( 'Enable Google Analytics 4', 'functionalities' ), + function () { + $o = self::get_snippets_options(); + $checked = ! empty( $o['enable_ga4'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_snippets', + 'functionalities_snippets_section' + ); + \add_settings_field( + 'ga4_id', + \__( 'GA4 Measurement ID', 'functionalities' ), + function () { + $o = self::get_snippets_options(); + $val = isset( $o['ga4_id'] ) ? (string) $o['ga4_id'] : ''; + echo ''; + }, + 'functionalities_snippets', + 'functionalities_snippets_section' + ); + \add_settings_field( + 'header_snippets', + \__( 'Header Snippets', 'functionalities' ), + function () { + self::field_snippets_repeater( 'header', 'wp_head' ); + }, + 'functionalities_snippets', + 'functionalities_snippets_section' + ); + \add_settings_field( + 'body_open_snippets', + \__( 'Body Open Snippets', 'functionalities' ), + function () { + self::field_snippets_repeater( 'body_open', 'wp_body_open' ); + }, + 'functionalities_snippets', + 'functionalities_snippets_section' + ); + \add_settings_field( + 'footer_snippets', + \__( 'Footer Snippets', 'functionalities' ), + function () { + self::field_snippets_repeater( 'footer', 'wp_footer' ); + }, + 'functionalities_snippets', + 'functionalities_snippets_section' + ); + + // Schema settings + \register_setting( + 'functionalities_schema', + 'functionalities_schema', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_schema' ), + 'default' => array( + 'enabled' => false, + 'enable_site_schema' => true, + 'site_itemtype' => 'WebPage', + 'enable_header_part' => true, + 'enable_footer_part' => true, + 'enable_article' => true, + 'article_itemtype' => 'Article', + 'add_headline' => true, + 'add_dates' => true, + 'add_author' => true, + 'enable_breadcrumbs' => false, + ), + ) + ); + + \add_settings_section( + 'functionalities_schema_section', + \__( 'Schema Settings', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Add Schema.org microdata attributes to improve search engine understanding of your content.', 'functionalities' ) . '

'; + + echo '
'; + echo '

' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

'; + echo '
    '; + echo '
  • ' . \esc_html__( 'Adds itemscope/itemtype to the HTML element for page-level schema', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Wraps article content with Article/BlogPosting microdata', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Adds structured data for headlines, dates, and authors', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Marks header and footer regions with WPHeader/WPFooter types', 'functionalities' ) . '
  • '; + echo '
'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'Supported Types', 'functionalities' ) . '

'; + echo '

'; + echo '' . \esc_html__( 'Site:', 'functionalities' ) . ' WebPage, AboutPage, ContactPage, Blog, SearchResultsPage
'; + echo '' . \esc_html__( 'Article:', 'functionalities' ) . ' Article, BlogPosting, NewsArticle'; + echo '

'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'For Developers', 'functionalities' ) . '

'; + echo '

'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_schema_enabled — ' . \esc_html__( 'toggle all schema output', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_schema_site_type — ' . \esc_html__( 'modify site itemtype', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_schema_article_type — ' . \esc_html__( 'modify article itemtype', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_schema_content — ' . \esc_html__( 'modify wrapped content', 'functionalities' ); + echo '

'; + echo '
'; + }, + 'functionalities_schema' + ); + + \add_settings_field( + 'enabled', + \__( 'Enable Schema', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + + \add_settings_field( + 'enable_site_schema', + \__( 'Enable site schema (html tag)', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['enable_site_schema'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'site_itemtype', + \__( 'Site itemtype', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $val = $o['site_itemtype'] ?? 'WebPage'; + $opts = array( 'WebPage', 'AboutPage', 'ContactPage', 'Blog', 'SearchResultsPage' ); + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'enable_header_part', + \__( 'Add WPHeader microdata', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['enable_header_part'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'enable_footer_part', + \__( 'Add WPFooter microdata', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['enable_footer_part'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'enable_article', + \__( 'Enable Article microdata in content', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['enable_article'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'article_itemtype', + \__( 'Article itemtype', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $val = $o['article_itemtype'] ?? 'Article'; + $opts = array( 'Article', 'BlogPosting', 'NewsArticle' ); + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'add_headline', + \__( 'Add headline from first heading', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['add_headline'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'add_dates', + \__( 'Add published/modified dates', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['add_dates'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'add_author', + \__( 'Add author microdata', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['add_author'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + \add_settings_field( + 'enable_breadcrumbs', + \__( 'Enable BreadcrumbList', 'functionalities' ), + function () { + $o = self::get_schema_options(); + $checked = ! empty( $o['enable_breadcrumbs'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_schema', + 'functionalities_schema_section' + ); + + // Components settings + \register_setting( + 'functionalities_components', + 'functionalities_components', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_components' ), + 'default' => array( + 'enabled' => false, + 'items' => self::default_components(), + ), + ) + ); + + \add_settings_section( + 'functionalities_components_section', + \__( 'Components', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Create reusable CSS components that are automatically loaded across your entire site.', 'functionalities' ) . '

'; + + echo '
'; + echo '

' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

'; + echo '
    '; + echo '
  • ' . \esc_html__( 'Define CSS class names and their style rules in one place', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Components are compiled into a single CSS file for optimal caching', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Available on both frontend and admin pages', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Includes default utility components like visually-hidden, skip-link, and marquee', 'functionalities' ) . '
  • '; + echo '
'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'How to Use', 'functionalities' ) . '

'; + echo '

' . \esc_html__( 'Add components by entering a CSS selector (e.g., .my-button) and CSS rules (e.g., background: blue; color: white;). Use the grid below to manage your components.', 'functionalities' ) . '

'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'For Developers', 'functionalities' ) . '

'; + echo '

'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_components_enabled — ' . \esc_html__( 'toggle output', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_components_items — ' . \esc_html__( 'add components dynamically', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_components_css — ' . \esc_html__( 'modify generated CSS', 'functionalities' ) . '
'; + echo \esc_html__( 'Action:', 'functionalities' ) . ' functionalities_components_updated — ' . \esc_html__( 'fires when CSS file regenerates', 'functionalities' ); + echo '

'; + echo '
'; + }, + 'functionalities_components' + ); + \add_settings_field( + 'enabled', + \__( 'Enable components CSS', 'functionalities' ), + function () { + $o = self::get_components_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_components', + 'functionalities_components_section' + ); + \add_settings_field( + 'items', + \__( 'Component list', 'functionalities' ), + array( __CLASS__, 'field_components_items' ), + 'functionalities_components', + 'functionalities_components_section' + ); + + // Miscellaneous (bloat control) + \register_setting( + 'functionalities_misc', + 'functionalities_misc', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_misc' ), + 'default' => array( + 'enabled' => false, + 'disable_block_widgets' => false, + 'load_separate_core_block_assets' => false, + 'disable_emojis' => false, + 'disable_embeds' => false, + 'remove_rest_api_links_head' => false, + 'remove_rsd_wlw_shortlink' => false, + 'remove_generator_meta' => false, + 'disable_xmlrpc' => false, + 'disable_xmlrpc_pingbacks' => false, + 'disable_feeds' => false, + 'disable_gravatars' => false, + 'disable_self_pingbacks' => false, + 'remove_query_strings' => false, + 'remove_dns_prefetch' => false, + 'remove_recent_comments_css' => false, + 'limit_revisions' => false, + 'disable_dashicons_for_guests' => false, + 'disable_heartbeat' => false, + 'disable_admin_bar_front' => false, + 'remove_jquery_migrate' => false, + 'enable_prism_admin' => false, + 'enable_textarea_fullscreen' => false, + ), + ) + ); + + \add_settings_section( + 'functionalities_misc_section', + \__( 'Miscellaneous (Bloat Control)', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Remove unnecessary WordPress features to improve performance and security.', 'functionalities' ) . '

'; + + echo '
'; + echo '

' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

'; + echo '
    '; + echo '
  • ' . \esc_html__( 'Remove bloat like emojis, oEmbeds, and unnecessary meta tags', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Disable security concerns like XML-RPC and version disclosure', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Improve performance by removing unused scripts and styles', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Add useful enhancements like PrismJS and fullscreen textareas', 'functionalities' ) . '
  • '; + echo '
'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'Caution', 'functionalities' ) . '

'; + echo '

' . \esc_html__( 'Some options may break functionality if plugins depend on them. Test after enabling. Disable Heartbeat API with care if you use auto-save or real-time features.', 'functionalities' ) . '

'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'For Developers', 'functionalities' ) . '

'; + echo '

'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_misc_options — ' . \esc_html__( 'modify options before application', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_misc_disable_emojis — ' . \esc_html__( 'control emoji removal', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_misc_disable_embeds — ' . \esc_html__( 'control embed removal', 'functionalities' ); + echo '

'; + echo '
'; + }, + 'functionalities_misc' + ); + + \add_settings_field( + 'enabled', + \__( 'Enable Performance & Cleanup', 'functionalities' ), + function () { + $o = self::get_misc_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_misc', + 'functionalities_misc_section' + ); + + self::add_misc_field( 'disable_block_widgets', \__( 'Disable block-based widget editor (use classic widgets)', 'functionalities' ) ); + self::add_misc_field( 'load_separate_core_block_assets', \__( 'Load core block styles separately (per-block CSS)', 'functionalities' ) ); + self::add_misc_field( 'disable_emojis', \__( 'Disable emojis scripts/styles', 'functionalities' ) ); + self::add_misc_field( 'disable_embeds', \__( 'Disable oEmbed scripts and endpoints', 'functionalities' ) ); + self::add_misc_field( 'remove_rest_api_links_head', \__( 'Remove REST API and oEmbed discovery links from ', 'functionalities' ) ); + self::add_misc_field( 'remove_rsd_wlw_shortlink', \__( 'Remove RSD, WLWManifest, and shortlink tags', 'functionalities' ) ); + self::add_misc_field( 'remove_generator_meta', \__( 'Remove WordPress version meta (generator)', 'functionalities' ) ); + self::add_misc_field( 'disable_xmlrpc', \__( 'Disable XML-RPC (complete)', 'functionalities' ) ); + self::add_misc_field( 'disable_xmlrpc_pingbacks', \__( 'Disable only XML-RPC Pingbacks', 'functionalities' ) ); + self::add_misc_field( 'disable_feeds', \__( 'Disable RSS/Atom feeds (redirect to homepage)', 'functionalities' ) ); + self::add_misc_field( 'disable_gravatars', \__( 'Disable Gravatars (site-wide)', 'functionalities' ) ); + self::add_misc_field( 'disable_self_pingbacks', \__( 'Disable self-pingbacks (pings to own site)', 'functionalities' ) ); + self::add_misc_field( 'remove_query_strings', \__( 'Remove query strings from static resources (?ver=)', 'functionalities' ) ); + self::add_misc_field( 'remove_dns_prefetch', \__( 'Remove DNS prefetch links from ', 'functionalities' ) ); + self::add_misc_field( 'remove_recent_comments_css', \__( 'Remove Recent Comments inline CSS', 'functionalities' ) ); + self::add_misc_field( 'limit_revisions', \__( 'Limit post revisions to 10', 'functionalities' ) ); + self::add_misc_field( 'disable_dashicons_for_guests', \__( 'Disable Dashicons on frontend for non-logged-in users', 'functionalities' ) ); + self::add_misc_field( 'disable_heartbeat', \__( 'Disable Heartbeat API', 'functionalities' ) ); + self::add_misc_field( 'disable_admin_bar_front', \__( 'Disable admin bar on the frontend', 'functionalities' ) ); + self::add_misc_field( 'remove_jquery_migrate', \__( 'Remove jQuery Migrate from frontend', 'functionalities' ) ); + self::add_misc_field( 'enable_prism_admin', \__( 'Load PrismJS on admin screens (code highlighting where applicable)', 'functionalities' ) ); + self::add_misc_field( 'enable_textarea_fullscreen', \__( 'Enable fullscreen toggle for all backend textareas', 'functionalities' ) ); + + // Fonts settings + \register_setting( + 'functionalities_fonts', + 'functionalities_fonts', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_fonts' ), + 'default' => array( + 'enabled' => false, + 'items' => array(), + ), + ) + ); + \add_settings_section( + 'functionalities_fonts_section', + \__( 'Font Families', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Self-host custom fonts with automatic @font-face CSS generation.', 'functionalities' ) . '

'; + + echo '
'; + echo '

' . \esc_html__( 'What This Module Does', 'functionalities' ) . '

'; + echo '
    '; + echo '
  • ' . \esc_html__( 'Generate @font-face CSS rules for self-hosted fonts', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Support for variable fonts with weight ranges (e.g., 100 900)', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'WOFF2 format for modern browsers, optional WOFF fallback', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Configurable font-display strategy (swap, auto, block, etc.)', 'functionalities' ) . '
  • '; + echo '
'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'How to Use', 'functionalities' ) . '

'; + echo '

' . \esc_html__( 'Upload font files to your media library or server, then add font entries below with the family name and file URLs. Use the generated font-family name in your CSS.', 'functionalities' ) . '

'; + echo '
'; + + echo '
'; + echo '

' . \esc_html__( 'For Developers', 'functionalities' ) . '

'; + echo '

'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_fonts_enabled — ' . \esc_html__( 'toggle output', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_fonts_items — ' . \esc_html__( 'add fonts dynamically', 'functionalities' ) . '
'; + echo \esc_html__( 'Filter:', 'functionalities' ) . ' functionalities_fonts_css — ' . \esc_html__( 'modify generated CSS', 'functionalities' ) . '
'; + echo \esc_html__( 'Action:', 'functionalities' ) . ' functionalities_fonts_before_output'; + echo '

'; + echo '
'; + }, + 'functionalities_fonts' + ); + \add_settings_field( + 'fonts_enabled', + \__( 'Enable fonts output', 'functionalities' ), + function () { + $o = self::get_fonts_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_fonts', + 'functionalities_fonts_section' + ); + \add_settings_field( + 'fonts_items', + \__( 'Families', 'functionalities' ), + array( __CLASS__, 'field_fonts_items' ), + 'functionalities_fonts', + 'functionalities_fonts_section' + ); + \add_settings_field( + 'fonts_assignments', + \__( 'Typography Assignments', 'functionalities' ), + array( __CLASS__, 'field_fonts_assignments' ), + 'functionalities_fonts', + 'functionalities_fonts_section' + ); + + // Login Security settings. + \register_setting( + 'functionalities_login_security', + 'functionalities_login_security', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_login_security' ), + 'default' => array( + 'enabled' => false, + 'limit_login_attempts' => true, + 'max_attempts' => 5, + 'lockout_duration' => 15, + 'disable_xmlrpc_auth' => true, + 'disable_application_passwords' => false, + 'hide_login_errors' => true, + 'trust_proxy_headers' => false, + 'custom_logo_url' => '', + 'custom_background_color' => '', + 'custom_form_background' => '', + ), + ) + ); + \add_settings_section( + 'functionalities_login_security_section', + \__( 'Login Security Settings', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Protect your login page and customize its appearance.', 'functionalities' ) . '

'; + echo '
'; + echo '

' . \esc_html__( 'Security Features', 'functionalities' ) . '

'; + echo '
    '; + echo '
  • ' . \esc_html__( 'Limit failed login attempts to prevent brute force attacks', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Disable XML-RPC authentication to block remote login attacks', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Hide specific login errors to prevent username enumeration', 'functionalities' ) . '
  • '; + echo '
  • ' . \esc_html__( 'Customize the login page with your logo and colors', 'functionalities' ) . '
  • '; + echo '
'; + echo '
'; + $logs = \Functionalities\Features\Login_Security::get_lockout_log( 5 ); + if ( ! empty( $logs ) ) { + echo '
'; + echo '

' . \esc_html__( 'Recent Lockouts', 'functionalities' ) . '

'; + echo '
    '; + foreach ( $logs as $log ) { + echo '
  • ' . \esc_html( $log['ip'] ) . ' — ' . \esc_html( $log['username'] ) . ' (' . \esc_html( $log['time'] ) . ')
  • '; + } + echo '
'; + echo '
'; + } + }, + 'functionalities_login_security' + ); + \add_settings_field( + 'login_enabled', + \__( 'Enable Login Security', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ''; + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'limit_login_attempts', + \__( 'Limit Login Attempts', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ''; + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'max_attempts', + \__( 'Max Attempts', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ' ' . \esc_html__( 'failed attempts before lockout', 'functionalities' ); + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'lockout_duration', + \__( 'Lockout Duration', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ' ' . \esc_html__( 'minutes', 'functionalities' ); + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'disable_xmlrpc_auth', + \__( 'Disable XML-RPC', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ''; + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'disable_application_passwords', + \__( 'Disable App Passwords', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ''; + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'hide_login_errors', + \__( 'Hide Login Errors', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ''; + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'trust_proxy_headers', + \__( 'Trust Proxy Headers', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ''; + echo '

' . \esc_html__( 'Only enable when this site sits behind a trusted reverse proxy or CDN (Cloudflare, nginx, etc.). With this OFF, lockouts are keyed by REMOTE_ADDR and cannot be spoofed via headers — the secure default.', 'functionalities' ) . '

'; + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'custom_logo_url', + \__( 'Custom Logo URL', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ''; + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + \add_settings_field( + 'custom_background_color', + \__( 'Background Color', 'functionalities' ), + function () { + $o = self::get_login_security_options(); + echo ''; + }, + 'functionalities_login_security', + 'functionalities_login_security_section' + ); + + // Meta & Copyright settings. + \register_setting( + 'functionalities_meta', + 'functionalities_meta', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_meta' ), + 'default' => array( + 'enabled' => false, + 'enable_copyright_meta' => true, + 'enable_dublin_core' => true, + 'enable_license_metabox' => true, + 'enable_schema_integration' => true, + 'default_license' => 'all-rights-reserved', + 'default_license_url' => '', + 'post_types' => array( 'post' ), + 'copyright_holder_type' => 'author', + 'custom_copyright_holder' => '', + 'dc_language' => '', + ), + ) + ); + + \add_settings_section( + 'functionalities_meta_section', + \__( 'Meta & Copyright Settings', 'functionalities' ), + array( __CLASS__, 'section_meta' ), + 'functionalities_meta' + ); + + \add_settings_field( + 'meta_enabled', + \__( 'Enable Meta Module', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'enable_copyright_meta', + \__( 'Copyright Meta Tags', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $checked = ! empty( $o['enable_copyright_meta'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'enable_dublin_core', + \__( 'Dublin Core (DCMI) Metadata', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $checked = ! empty( $o['enable_dublin_core'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'enable_license_metabox', + \__( 'License Metabox', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $checked = ! empty( $o['enable_license_metabox'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'enable_schema_integration', + \__( 'SEO Plugin Schema Integration', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $checked = ! empty( $o['enable_schema_integration'] ) ? 'checked' : ''; + $detected = \Functionalities\Features\Meta::detect_seo_plugin(); + $plugin_names = array( + 'rank-math' => 'Rank Math', + 'yoast' => 'Yoast SEO', + 'seo-framework' => 'The SEO Framework', + 'seopress' => 'SEOPress', + 'aioseo' => 'All in One SEO', + 'none' => \__( 'None detected', 'functionalities' ), + ); + echo ''; + echo '

'; + if ( $detected !== 'none' ) { + echo '✓ ' . \esc_html__( 'Detected:', 'functionalities' ) . ' ' . \esc_html( $plugin_names[ $detected ] ) . ''; + } else { + echo \esc_html__( 'Supports: Rank Math, Yoast SEO, The SEO Framework, SEOPress, AIOSEO', 'functionalities' ); + } + echo '

'; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'default_license', + \__( 'Default License', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $val = isset( $o['default_license'] ) ? (string) $o['default_license'] : 'all-rights-reserved'; + $licenses = array( + 'all-rights-reserved' => \__( 'All Rights Reserved', 'functionalities' ), + 'cc-by' => 'CC BY 4.0', + 'cc-by-sa' => 'CC BY-SA 4.0', + 'cc-by-nc' => 'CC BY-NC 4.0', + 'cc-by-nc-sa' => 'CC BY-NC-SA 4.0', + 'cc-by-nd' => 'CC BY-ND 4.0', + 'cc-by-nc-nd' => 'CC BY-NC-ND 4.0', + 'cc0' => 'CC0 1.0 (Public Domain)', + ); + echo ''; + echo '

' . \esc_html__( 'Default license for new posts (can be overridden per-post).', 'functionalities' ) . '

'; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'default_license_url', + \__( 'Custom License URL', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $val = isset( $o['default_license_url'] ) ? (string) $o['default_license_url'] : ''; + echo ''; + echo '

' . \esc_html__( 'Custom URL for "All Rights Reserved" license (e.g., your terms/disclaimer page).', 'functionalities' ) . '

'; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'meta_post_types', + \__( 'Post Types', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $selected = isset( $o['post_types'] ) && is_array( $o['post_types'] ) ? $o['post_types'] : array( 'post' ); + $pts = \get_post_types( array( 'public' => true ), 'objects' ); + echo '
'; + foreach ( $pts as $name => $obj ) { + $is_checked = in_array( $name, $selected, true ) ? 'checked' : ''; + $label = sprintf( '%s (%s)', $obj->labels->singular_name ?? $name, $name ); + echo ''; + } + echo '
'; + echo '

' . \esc_html__( 'Select post types where meta tags and license metabox should appear.', 'functionalities' ) . '

'; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'copyright_holder_type', + \__( 'Copyright Holder', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $val = isset( $o['copyright_holder_type'] ) ? (string) $o['copyright_holder_type'] : 'author'; + $options = array( + 'author' => \__( 'Post Author', 'functionalities' ), + 'site' => \__( 'Site Name', 'functionalities' ), + 'custom' => \__( 'Custom Name', 'functionalities' ), + ); + echo ''; + echo '

' . \esc_html__( 'Who should be listed as the copyright holder.', 'functionalities' ) . '

'; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'custom_copyright_holder', + \__( 'Custom Copyright Holder Name', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $val = isset( $o['custom_copyright_holder'] ) ? (string) $o['custom_copyright_holder'] : ''; + echo ''; + echo '

' . \esc_html__( 'Used when "Custom Name" is selected above.', 'functionalities' ) . '

'; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + \add_settings_field( + 'dc_language', + \__( 'Dublin Core Language', 'functionalities' ), + function () { + $o = self::get_meta_options(); + $val = isset( $o['dc_language'] ) ? (string) $o['dc_language'] : ''; + $site_lang = \get_bloginfo( 'language' ); + echo ''; + echo '

' . \esc_html__( 'Leave empty to use site language. Use ISO 639 codes (en, en-US, de, etc.).', 'functionalities' ) . '

'; + }, + 'functionalities_meta', + 'functionalities_meta_section' + ); + + // Content Regression Detection settings. + \register_setting( + 'functionalities_content_regression', + 'functionalities_content_regression', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_content_regression' ), + 'default' => array( + 'enabled' => false, + 'post_types' => array( 'post', 'page' ), + 'link_drop_enabled' => true, + 'link_drop_percent' => 30, + 'link_drop_absolute' => 3, + 'exclude_nofollow_links' => false, + 'word_count_enabled' => true, + 'word_count_drop_percent' => 35, + 'word_count_min_age_days' => 30, + 'word_count_compare_average' => false, + 'exclude_shortcodes' => false, + 'heading_enabled' => true, + 'detect_missing_h1' => true, + 'detect_multiple_h1' => true, + 'detect_skipped_levels' => true, + 'snapshot_rolling_count' => 5, + 'show_post_column' => true, + ), + ) + ); + + \add_settings_section( + 'functionalities_content_regression_section', + \__( 'Content Integrity Settings', 'functionalities' ), + array( __CLASS__, 'section_content_regression' ), + 'functionalities_content_regression' + ); + + // Enable module. + \add_settings_field( + 'regression_enabled', + \__( 'Enable Content Integrity', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + // Post types. + \add_settings_field( + 'regression_post_types', + \__( 'Post Types', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $selected = isset( $o['post_types'] ) && is_array( $o['post_types'] ) ? $o['post_types'] : array( 'post', 'page' ); + $pts = \get_post_types( array( 'public' => true ), 'objects' ); + echo '
'; + foreach ( $pts as $name => $obj ) { + if ( 'attachment' === $name ) { + continue; + } + $is_checked = in_array( $name, $selected, true ) ? 'checked' : ''; + $label = sprintf( '%s (%s)', $obj->labels->singular_name ?? $name, $name ); + echo ''; + } + echo '
'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + // Link drop detection header. + \add_settings_field( + 'link_drop_header', + '' . \__( 'Internal Link Detection', 'functionalities' ) . '', + function () { + echo '

' . \esc_html__( 'Detect when internal links are removed from content.', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'link_drop_enabled', + \__( 'Enable Link Detection', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['link_drop_enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'link_drop_percent', + \__( 'Link Drop Threshold (%)', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $val = isset( $o['link_drop_percent'] ) ? (int) $o['link_drop_percent'] : 30; + echo ' %'; + echo '

' . \esc_html__( 'Warn if internal links drop by this percentage or more.', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'link_drop_absolute', + \__( 'Link Drop Absolute Threshold', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $val = isset( $o['link_drop_absolute'] ) ? (int) $o['link_drop_absolute'] : 3; + echo ' ' . \esc_html__( 'links', 'functionalities' ); + echo '

' . \esc_html__( 'Also warn if this many links are removed (whichever triggers first).', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'exclude_nofollow_links', + \__( 'Exclude Nofollow Links', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['exclude_nofollow_links'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + // Word count detection header. + \add_settings_field( + 'word_count_header', + '' . \__( 'Word Count Detection', 'functionalities' ) . '', + function () { + echo '

' . \esc_html__( 'Detect when content is shortened significantly.', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'word_count_enabled', + \__( 'Enable Word Count Detection', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['word_count_enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'word_count_drop_percent', + \__( 'Word Count Drop Threshold (%)', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $val = isset( $o['word_count_drop_percent'] ) ? (int) $o['word_count_drop_percent'] : 35; + echo ' %'; + echo '

' . \esc_html__( 'Warn if word count drops by this percentage.', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'word_count_min_age_days', + \__( 'Minimum Post Age (days)', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $val = isset( $o['word_count_min_age_days'] ) ? (int) $o['word_count_min_age_days'] : 30; + echo ' ' . \esc_html__( 'days', 'functionalities' ); + echo '

' . \esc_html__( 'Only check word count for posts older than this (to avoid alerts on new posts).', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'exclude_shortcodes', + \__( 'Exclude Shortcodes', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['exclude_shortcodes'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + // Heading detection header. + \add_settings_field( + 'heading_header', + '' . \__( 'Heading Structure Detection', 'functionalities' ) . '', + function () { + echo '

' . \esc_html__( 'Detect accessibility issues with heading hierarchy.', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'heading_enabled', + \__( 'Enable Heading Detection', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['heading_enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'detect_missing_h1', + \__( 'Detect Missing H1', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['detect_missing_h1'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'detect_multiple_h1', + \__( 'Detect Multiple H1s', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['detect_multiple_h1'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'detect_skipped_levels', + \__( 'Detect Skipped Levels', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['detect_skipped_levels'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + // Advanced settings header. + \add_settings_field( + 'advanced_header', + '' . \__( 'Advanced Settings', 'functionalities' ) . '', + function () { + echo '

' . \esc_html__( 'Configure snapshot and display options.', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'snapshot_rolling_count', + \__( 'Snapshots to Keep', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $val = isset( $o['snapshot_rolling_count'] ) ? (int) $o['snapshot_rolling_count'] : 5; + echo ''; + echo '

' . \esc_html__( 'Number of historical snapshots to retain per post.', 'functionalities' ) . '

'; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + \add_settings_field( + 'show_post_column', + \__( 'Show Post List Column', 'functionalities' ), + function () { + $o = self::get_content_regression_options(); + $checked = ! empty( $o['show_post_column'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_content_regression', + 'functionalities_content_regression_section' + ); + + // Assumption Detection settings. + \register_setting( + 'functionalities_assumption_detection', + 'functionalities_assumption_detection', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_assumption_detection' ), + 'default' => array( + 'enabled' => false, + 'detect_schema_collision' => true, + 'detect_analytics_dupe' => true, + 'detect_font_redundancy' => true, + 'detect_inline_css_growth' => true, + 'inline_css_threshold_kb' => 50, + 'detect_jquery_conflicts' => true, + 'detect_meta_duplication' => true, + 'detect_rest_exposure' => true, + 'detect_lazy_load_conflict' => true, + 'detect_mixed_content' => true, + 'detect_missing_security_headers' => true, + 'detect_debug_exposure' => true, + 'detect_cron_issues' => true, + ), + ) + ); + + \add_settings_section( + 'functionalities_assumption_detection_section', + \__( 'Assumption Detection Settings', 'functionalities' ), + array( __CLASS__, 'section_assumption_detection' ), + 'functionalities_assumption_detection' + ); + + \add_settings_field( + 'assumption_enabled', + \__( 'Enable Assumption Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_schema_collision', + \__( 'Schema Collision Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_schema_collision'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_analytics_dupe', + \__( 'Analytics Duplication Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_analytics_dupe'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_font_redundancy', + \__( 'Font Redundancy Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_font_redundancy'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_inline_css_growth', + \__( 'Inline CSS Growth Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_inline_css_growth'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'inline_css_threshold_kb', + \__( 'Inline CSS Threshold (KB)', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $val = isset( $o['inline_css_threshold_kb'] ) ? (int) $o['inline_css_threshold_kb'] : 50; + echo ' KB'; + echo '

' . \esc_html__( 'Warn when inline CSS exceeds this size.', 'functionalities' ) . '

'; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_jquery_conflicts', + \__( 'jQuery Conflict Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_jquery_conflicts'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_meta_duplication', + \__( 'Meta Tag Duplication Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_meta_duplication'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_rest_exposure', + \__( 'REST API Exposure Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_rest_exposure'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_lazy_load_conflict', + \__( 'Lazy Loading Conflict Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_lazy_load_conflict'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_mixed_content', + \__( 'Mixed Content Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_mixed_content'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_missing_security_headers', + \__( 'Security Headers Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_missing_security_headers'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_debug_exposure', + \__( 'Debug Mode Exposure', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_debug_exposure'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detect_cron_issues', + \__( 'Cron Issues Detection', 'functionalities' ), + function () { + $o = self::get_assumption_detection_options(); + $checked = ! empty( $o['detect_cron_issues'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + // Explicit save button field - ensures visibility. + \add_settings_field( + 'assumption_save_settings', + '', + function () { + echo '
'; + \submit_button( \__( 'Save Settings', 'functionalities' ), 'primary', 'submit', false ); + echo ' '; + echo ''; + echo '
'; + }, + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + \add_settings_field( + 'detected_assumptions', + \__( 'Detected Assumptions', 'functionalities' ), + array( __CLASS__, 'field_detected_assumptions' ), + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + + // PWA settings. + \register_setting( + 'functionalities_pwa', + 'functionalities_pwa', + array( + 'sanitize_callback' => array( __CLASS__, 'sanitize_pwa' ), + 'default' => array( + 'enabled' => false, + ), + ) + ); + + \add_settings_section( + 'functionalities_pwa_identity', + \__( 'App Identity', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Basic information about your progressive web app.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa' + ); + + \add_settings_field( + 'pwa_enabled', + \__( 'Enable PWA', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $checked = ! empty( $o['enabled'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + \add_settings_field( + 'pwa_app_name', + \__( 'App Name', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'Full name displayed on install. Leave blank to use site title.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + \add_settings_field( + 'pwa_short_name', + \__( 'Short Name', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'Short name for the home screen (12 chars max).', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + \add_settings_field( + 'pwa_description', + \__( 'Description', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'Shown in app stores and install dialogs. Leave blank to use site tagline.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + \add_settings_field( + 'pwa_start_url', + \__( 'Start URL', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + \add_settings_field( + 'pwa_scope', + \__( 'Scope', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'Navigation scope. "/" means the entire site.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + \add_settings_field( + 'pwa_categories', + \__( 'Categories', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'Comma-separated W3C categories (e.g. news, education, business).', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + \add_settings_field( + 'pwa_display', + \__( 'Display Mode', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $modes = array( 'standalone', 'fullscreen', 'minimal-ui', 'browser' ); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + \add_settings_field( + 'pwa_orientation', + \__( 'Orientation', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $orientations = array( 'any', 'portrait', 'landscape', 'portrait-primary', 'landscape-primary' ); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_identity' + ); + + // Appearance section. + \add_settings_section( + 'functionalities_pwa_appearance', + \__( 'Appearance & Icons', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Theme colors and app icons. Icons must be square PNG files.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa' + ); + + \add_settings_field( + 'pwa_theme_color', + \__( 'Theme Color', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_appearance' + ); + + \add_settings_field( + 'pwa_background_color', + \__( 'Background Color', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_appearance' + ); + + \add_settings_field( + 'pwa_icon_512', + \__( 'Icon 512x512', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + self::render_media_field( 'functionalities_pwa[icon_512]', $o['icon_512'], \__( 'Primary app icon (required for installability).', 'functionalities' ) ); + }, + 'functionalities_pwa', + 'functionalities_pwa_appearance' + ); + + \add_settings_field( + 'pwa_icon_192', + \__( 'Icon 192x192', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + self::render_media_field( 'functionalities_pwa[icon_192]', $o['icon_192'], \__( 'Smaller icon for home screen and splash.', 'functionalities' ) ); + }, + 'functionalities_pwa', + 'functionalities_pwa_appearance' + ); + + \add_settings_field( + 'pwa_maskable_icon_512', + \__( 'Maskable Icon 512x512', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + self::render_media_field( 'functionalities_pwa[maskable_icon_512]', $o['maskable_icon_512'], \__( 'Maskable icon for adaptive icon support (safe zone padding recommended).', 'functionalities' ) ); + }, + 'functionalities_pwa', + 'functionalities_pwa_appearance' + ); + + \add_settings_field( + 'pwa_maskable_icon_192', + \__( 'Maskable Icon 192x192', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + self::render_media_field( 'functionalities_pwa[maskable_icon_192]', $o['maskable_icon_192'], \__( 'Smaller maskable icon.', 'functionalities' ) ); + }, + 'functionalities_pwa', + 'functionalities_pwa_appearance' + ); + + // Install Prompt section. + \add_settings_section( + 'functionalities_pwa_prompt', + \__( 'Install Prompt', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Customize the in-page install prompt shown to visitors.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa' + ); + + \add_settings_field( + 'pwa_install_prompt', + \__( 'Show Install Prompt', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $checked = ! empty( $o['install_prompt'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_prompt' + ); + + \add_settings_field( + 'pwa_prompt_title', + \__( 'Prompt Title', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_prompt' + ); + + \add_settings_field( + 'pwa_prompt_text', + \__( 'Prompt Text', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_prompt' + ); + + \add_settings_field( + 'pwa_prompt_button', + \__( 'Button Text', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_prompt' + ); + + \add_settings_field( + 'pwa_prompt_dismiss', + \__( 'Dismiss Text', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_prompt' + ); + + \add_settings_field( + 'pwa_prompt_position', + \__( 'Position', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $positions = array( + 'bottom' => \__( 'Bottom', 'functionalities' ), + 'top' => \__( 'Top', 'functionalities' ), + 'center' => \__( 'Center (modal)', 'functionalities' ), + ); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_prompt' + ); + + \add_settings_field( + 'pwa_prompt_style', + \__( 'Style', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $styles = array( + 'banner' => \__( 'Banner', 'functionalities' ), + 'card' => \__( 'Card', 'functionalities' ), + ); + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_prompt' + ); + + \add_settings_field( + 'pwa_prompt_frequency', + \__( 'Re-show After (days)', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'Days to wait before showing prompt again after dismissal.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_prompt' + ); + + // Offline & Caching section. + \add_settings_section( + 'functionalities_pwa_caching', + \__( 'Offline & Caching', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Service worker caching and offline behavior.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa' + ); + + \add_settings_field( + 'pwa_cache_version', + \__( 'Cache Version', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'Increment to force cache refresh (e.g. v1, v2).', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_caching' + ); + + \add_settings_field( + 'pwa_precache_urls', + \__( 'Precache URLs', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'One URL per line. These pages will be cached immediately when the service worker installs.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_caching' + ); + + // Shortcuts section. + \add_settings_section( + 'functionalities_pwa_shortcuts', + \__( 'Shortcuts', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'App shortcut links shown on long-press of the app icon. Up to 4 shortcuts.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa' + ); + + \add_settings_field( + 'pwa_shortcuts', + \__( 'Shortcuts', 'functionalities' ), + array( __CLASS__, 'field_pwa_shortcuts' ), + 'functionalities_pwa', + 'functionalities_pwa_shortcuts' + ); + + // Screenshots section. + \add_settings_section( + 'functionalities_pwa_screenshots', + \__( 'Screenshots', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'App screenshots shown in install dialogs and app stores. Provide wide and narrow sizes.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa' + ); + + \add_settings_field( + 'pwa_screenshots', + \__( 'Screenshots', 'functionalities' ), + array( __CLASS__, 'field_pwa_screenshots' ), + 'functionalities_pwa', + 'functionalities_pwa_screenshots' + ); + + // Advanced section. + \add_settings_section( + 'functionalities_pwa_advanced', + \__( 'Advanced', 'functionalities' ), + function () { + echo '

' . \esc_html__( 'Advanced PWA features. Only change these if you know what you\'re doing.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa' + ); + + \add_settings_field( + 'pwa_display_override', + \__( 'Display Override', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $checked = ! empty( $o['display_override'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_advanced' + ); + + \add_settings_field( + 'pwa_edge_side_panel', + \__( 'Edge Side Panel', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $checked = ! empty( $o['edge_side_panel'] ) ? 'checked' : ''; + echo ''; + }, + 'functionalities_pwa', + 'functionalities_pwa_advanced' + ); + + \add_settings_field( + 'pwa_launch_handler', + \__( 'Launch Handler', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $handlers = array( + '' => \__( 'Default', 'functionalities' ), + 'focus-existing' => 'focus-existing', + 'navigate-new' => 'navigate-new', + 'navigate-existing' => 'navigate-existing', + ); + echo ''; + echo '

' . \esc_html__( 'Controls behavior when the app is already open and a new navigation is triggered.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_advanced' + ); + + \add_settings_field( + 'pwa_share_target', + \__( 'Share Target', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + $checked = ! empty( $o['share_target_enabled'] ) ? 'checked' : ''; + echo ''; + echo '
'; + echo ''; + echo '
'; + $methods = array( + 'GET' => 'GET', + 'POST' => 'POST', + ); + echo '
'; + echo ''; + echo '
'; + }, + 'functionalities_pwa', + 'functionalities_pwa_advanced' + ); + + \add_settings_field( + 'pwa_advanced_manifest', + \__( 'Custom Manifest JSON', 'functionalities' ), + function () { + $o = self::get_pwa_options(); + echo ''; + echo '

' . \esc_html__( 'Raw JSON to merge into the manifest. Must be a valid JSON object.', 'functionalities' ) . '

'; + }, + 'functionalities_pwa', + 'functionalities_pwa_advanced' + ); + } + + public static function field_nofollow_external(): void { + $opts = self::get_link_management_options(); + $checked = ! empty( $opts['nofollow_external'] ) ? 'checked' : ''; + echo ''; + } + + /** + * Render exceptions field. + * + * @return void + */ + public static function field_exceptions(): void { + $opts = self::get_link_management_options(); + $value = isset( $opts['exceptions'] ) ? (string) $opts['exceptions'] : ''; + echo ''; + echo '

' . \esc_html__( 'One per line. Supports full URLs (https://example.com/page), domains (example.com), or partial matches (e.g., /partner/).', 'functionalities' ) . '

'; + } + + /** + * Render section description for link management. + * + * @return void + */ + public static function section_link_management(): void { + echo '

' . \esc_html__( 'Control how external and internal links are handled across your site.', 'functionalities' ) . '

'; + + // Get module docs. + $docs = Module_Docs::get( 'link-management' ); + + // Render documentation accordions. + echo '
'; + + if ( ! empty( $docs['features'] ) ) { + $list = '
    '; + foreach ( $docs['features'] as $feature ) { + $list .= '
  • ' . \esc_html( $feature ) . '
  • '; + } + $list .= '
'; + Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); + } + + if ( ! empty( $docs['hooks'] ) ) { + $hooks_html = '
'; + foreach ( $docs['hooks'] as $hook ) { + $hooks_html .= '
' . \esc_html( $hook['name'] ) . '
'; + $hooks_html .= '
' . \esc_html( $hook['description'] ) . '
'; + } + $hooks_html .= '
'; + Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); + } + + echo '
'; + } + + /** + * Render open external links in new tab field. + * + * @return void + */ + public static function field_open_external_new_tab(): void { + $opts = self::get_link_management_options(); + $checked = ! empty( $opts['open_external_new_tab'] ) ? 'checked' : ''; + echo ''; + } + + /** + * Render open internal links in new tab field. + * + * @return void + */ + public static function field_open_internal_new_tab(): void { + $opts = self::get_link_management_options(); + $checked = ! empty( $opts['open_internal_new_tab'] ) ? 'checked' : ''; + echo ''; + } + + /** + * Render internal new tab exceptions field. + * + * @return void + */ + public static function field_internal_new_tab_exceptions(): void { + $opts = self::get_link_management_options(); + $val = isset( $opts['internal_new_tab_exceptions'] ) ? (string) $opts['internal_new_tab_exceptions'] : ''; + echo ''; + echo '

' . \esc_html__( 'One domain per line. Matching hosts will NOT be forced to open in a new tab when internal option is enabled.', 'functionalities' ) . '

'; + } + + /** + * Render JSON preset URL field. + * + * @return void + */ + public static function field_json_preset_url(): void { + $opts = self::get_link_management_options(); + $val = isset( $opts['json_preset_url'] ) ? (string) $opts['json_preset_url'] : ''; + + // Check for theme exception-urls.json. + $theme_json_path = \get_stylesheet_directory() . '/exception-urls.json'; + $theme_json_exists = file_exists( $theme_json_path ); + $parent_json_path = \get_template_directory() . '/exception-urls.json'; + $parent_json_exists = \get_stylesheet_directory() !== \get_template_directory() && file_exists( $parent_json_path ); + ?> +
+
+ + + +
+ +

+ +

+

+ +

+ + +
+ + +

+ get( 'Name' ) ) + ); + ?> +
+

+ + +

+ get( 'Name' ) ) + ); + ?> +
+

+ +
+ +

+ + +

+ + +
+ +

+ +

+

+ +

+
+ +

+ +

+
+ + + + + + + + +

+ +

+ +
+

+ +
+ + + +
+

+
<?php
+/**
+ * Add trusted domains to nofollow exceptions.
+ * Links to these domains will NOT have nofollow added.
+ */
+add_filter( 'functionalities_exception_domains', function( $domains ) {
+	// Add your trusted domains here
+	$trusted_domains = array(
+		'trusted-partner.com',
+		'another-trusted-site.org',
+		'your-other-website.net',
+	);
+
+	return array_merge( $domains, $trusted_domains );
+} );
+ +
+
+ +
+ + + +
+

+
<?php
+/**
+ * Add specific URLs to nofollow exceptions.
+ * These specific URLs will NOT have nofollow added.
+ */
+add_filter( 'functionalities_exception_urls', function( $urls ) {
+	// Add specific URLs or patterns
+	$trusted_urls = array(
+		'https://example.com/specific-page',
+		'https://partner.com/affiliate/*',
+		'https://trusted.org/blog/*',
+	);
+
+	return array_merge( $urls, $trusted_urls );
+} );
+ +
+
+ +
+ + + +
+

+
<?php
+/**
+ * Dynamically add exception domains based on conditions.
+ * Example: Trust all domains for administrators.
+ */
+add_filter( 'functionalities_exception_domains', function( $domains ) {
+	// Skip nofollow for administrators
+	if ( current_user_can( 'manage_options' ) ) {
+		// Return wildcard to match all domains
+		$domains[] = '*';
+	}
+
+	// Add domains from a custom option
+	$custom_domains = get_option( 'my_trusted_domains', array() );
+	if ( is_array( $custom_domains ) ) {
+		$domains = array_merge( $domains, $custom_domains );
+	}
+
+	// Add sponsor domains on specific post types
+	if ( is_singular( 'sponsored_post' ) ) {
+		$domains[] = 'sponsor-website.com';
+	}
+
+	return $domains;
+} );
+ +
+
+ +
+ + + +
+

+
<?php
+/**
+ * Use a custom JSON file path for exception URLs.
+ */
+add_filter( 'functionalities_json_preset_path', function( $default_path ) {
+	// Use a JSON file from your theme
+	$theme_json = get_stylesheet_directory() . '/config/exceptions.json';
+
+	if ( file_exists( $theme_json ) ) {
+		return $theme_json;
+	}
+
+	// Or use an external URL (use with caution!)
+	// return 'https://your-cdn.com/exception-urls.json';
+
+	return $default_path;
+} );
+ +
+
+ + +
+ + + +
+

+ +

+ + +
+ +
+ ' . \esc_html__( 'Remove Gutenberg block-specific CSS classes from your frontend HTML for cleaner markup.', 'functionalities' ) . '

'; + + // Get module docs. + $docs = Module_Docs::get( 'block-cleanup' ); + + // Render documentation accordions. + echo '
'; + + if ( ! empty( $docs['features'] ) ) { + $list = '
    '; + foreach ( $docs['features'] as $feature ) { + $list .= '
  • ' . \esc_html( $feature ) . '
  • '; + } + $list .= '
'; + Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); + } + + // Classes removed info. + $classes_html = '
    '; + $classes_html .= '
  • wp-block-heading → ' . \esc_html__( 'from h1-h6 elements', 'functionalities' ) . '
  • '; + $classes_html .= '
  • wp-block-list → ' . \esc_html__( 'from ul/ol elements', 'functionalities' ) . '
  • '; + $classes_html .= '
  • wp-block-image → ' . \esc_html__( 'from figure/div elements', 'functionalities' ) . '
  • '; + $classes_html .= '
'; + Admin_UI::render_docs_section( \__( 'Classes Removed', 'functionalities' ), $classes_html, 'usage' ); + + if ( ! empty( $docs['hooks'] ) ) { + $hooks_html = '
'; + foreach ( $docs['hooks'] as $hook ) { + $hooks_html .= '
' . \esc_html( $hook['name'] ) . '
'; + $hooks_html .= '
' . \esc_html( $hook['description'] ) . '
'; + } + $hooks_html .= '
'; + Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); + } + + echo '
'; + } + + // Block Cleanup: fields & helpers + public static function field_bc_remove_heading(): void { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_heading_block_class'] ) ? 'checked' : ''; + echo ''; + } + public static function field_bc_remove_list(): void { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_list_block_class'] ) ? 'checked' : ''; + echo ''; + } + public static function field_bc_remove_image(): void { + $opts = self::get_block_cleanup_options(); + $checked = ! empty( $opts['remove_image_block_class'] ) ? 'checked' : ''; + echo ''; + } + + // Editor Links: fields & helpers + public static function field_el_enable(): void { + $opts = self::get_editor_links_options(); + $checked = ! empty( $opts['enable_limit'] ) ? 'checked' : ''; + echo ''; + } + public static function field_el_post_types(): void { + $opts = self::get_editor_links_options(); + $selected = isset( $opts['post_types'] ) && is_array( $opts['post_types'] ) ? $opts['post_types'] : array(); + $pts = get_post_types( array( 'public' => true ), 'objects' ); + echo '
'; + foreach ( $pts as $name => $obj ) { + $is_checked = in_array( $name, $selected, true ) ? 'checked' : ''; + $label = sprintf( '%s (%s)', $obj->labels->singular_name ?? $name, $name ); + echo ''; + } + echo '
'; + } + + public static function field_components_items(): void { + $o = self::get_components_options(); + $items = isset( $o['items'] ) && is_array( $o['items'] ) ? $o['items'] : array(); + $per_page = 24; + $total_items = count( $items ); + $total_pages = (int) max( 1, ceil( $total_items / $per_page ) ); + ?> + + +
+
+ + + + +
+ +
+ +
+
+
+ +
+
+
+
+

+
+ +
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + 1 ) : ?> +
+ + + + + + + + +
+ + + + +
+ + + +

+ +

+ ' . esc_html( $label ) . ''; + }, + 'functionalities_misc', + 'functionalities_misc_section' + ); + } + + public static function field_fonts_items(): void { + $o = self::get_fonts_options(); + $items = isset( $o['items'] ) && is_array( $o['items'] ) ? $o['items'] : array(); + $total_items = count( $items ); + + // Enqueue media uploader + \wp_enqueue_media(); + ?> + + +
+
+ + + + +
+ +
+ +
+
🔤
+

+

+
+ + + +
+
+
+

+
+ + + + + + + +
+
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ > + +
+
+ > + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ + +

+ +

+
+
+ + +
+
+
+ +
+
+ + + + + + + + +

+ +

+ +
+ +

+
+ +
+ +

+ + + + + + + + + + + + + + + + +
+ + + ' . \esc_html__( 'Add copyright metadata, Dublin Core (DCMI) tags, and per-post licensing options. Works standalone or integrates with major SEO plugins.', 'functionalities' ) . '

'; + + // Get module docs. + $docs = Module_Docs::get( 'meta' ); + + // Render documentation accordions. + echo '
'; + + if ( ! empty( $docs['features'] ) ) { + $list = '
    '; + foreach ( $docs['features'] as $feature ) { + $list .= '
  • ' . \esc_html( $feature ) . '
  • '; + } + $list .= '
'; + Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); + } + + // Schema.org Support (dynamic based on detected plugin). + $plugin_names = array( + 'rank-math' => 'Rank Math', + 'yoast' => 'Yoast SEO', + 'seo-framework' => 'The SEO Framework', + 'seopress' => 'SEOPress', + 'aioseo' => 'All in One SEO', + ); + + if ( $detected !== 'none' ) { + $schema_content = '

✓ ' . \esc_html__( 'Detected:', 'functionalities' ) . ' ' . \esc_html( $plugin_names[ $detected ] ?? $detected ) . '

'; + $schema_content .= '

' . \esc_html__( 'Copyright data will be added to your SEO plugin\'s existing schema output.', 'functionalities' ) . '

'; + } else { + $schema_content = '

✓ ' . \esc_html__( 'Standalone Mode', 'functionalities' ) . '

'; + $schema_content .= '

' . \esc_html__( 'No SEO plugin detected. Complete Article schema with copyright will be output independently.', 'functionalities' ) . '

'; + } + Admin_UI::render_docs_section( \__( 'Schema.org Support', 'functionalities' ), $schema_content, 'developer', true ); + + // Compatible plugins. + $plugins_html = '
    '; + $plugins_html .= '
  • Rank Math
  • '; + $plugins_html .= '
  • Yoast SEO
  • '; + $plugins_html .= '
  • The SEO Framework
  • '; + $plugins_html .= '
  • SEOPress
  • '; + $plugins_html .= '
  • All in One SEO
  • '; + $plugins_html .= '
  • ' . \esc_html__( 'or Standalone', 'functionalities' ) . '
  • '; + $plugins_html .= '
'; + Admin_UI::render_docs_section( \__( 'Compatible SEO Plugins', 'functionalities' ), $plugins_html, 'usage' ); + + if ( ! empty( $docs['hooks'] ) ) { + $hooks_html = '
'; + foreach ( $docs['hooks'] as $hook ) { + $hooks_html .= '
' . \esc_html( $hook['name'] ) . '
'; + $hooks_html .= '
' . \esc_html( $hook['description'] ) . '
'; + } + $hooks_html .= '
'; + Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); + } + + echo '
'; + } + + /** + * Render section description for content regression detection. + * + * @return void + */ + public static function section_content_regression(): void { + echo '

' . \esc_html__( 'Detect structural regressions when posts are updated. This module compares each post against its own historical baseline.', 'functionalities' ) . '

'; + + // Get module docs. + $docs = Module_Docs::get( 'content-regression' ); + + // Render documentation accordions. + echo '
'; + + if ( ! empty( $docs['features'] ) ) { + $list = '
    '; + foreach ( $docs['features'] as $feature ) { + $list .= '
  • ' . \esc_html( $feature ) . '
  • '; + } + $list .= '
'; + Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); + } + + if ( ! empty( $docs['usage'] ) ) { + Admin_UI::render_docs_section( \__( 'Philosophy', 'functionalities' ), '

' . \esc_html( $docs['usage'] ) . '

', 'usage' ); + } + + if ( ! empty( $docs['hooks'] ) ) { + $hooks_html = '
'; + foreach ( $docs['hooks'] as $hook ) { + $hooks_html .= '
' . \esc_html( $hook['name'] ) . '
'; + $hooks_html .= '
' . \esc_html( $hook['description'] ) . '
'; + } + $hooks_html .= '
'; + Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); + } + + echo '
'; + } + + /** + * Assumption detection section callback. + * + * @return void + */ + public static function section_assumption_detection(): void { + echo '

' . \esc_html__( 'Detects when technical assumptions stop being true. This module notices changes, not problems.', 'functionalities' ) . '

'; + + // Get module docs. + $docs = Module_Docs::get( 'assumption-detection' ); + + // Render documentation accordions. + if ( ! empty( $docs['features'] ) ) { + $list = '
    '; + foreach ( $docs['features'] as $feature ) { + $list .= '
  • ' . \esc_html( $feature ) . '
  • '; + } + $list .= '
'; + Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); + } + + if ( ! empty( $docs['usage'] ) ) { + Admin_UI::render_docs_section( \__( 'How to Use', 'functionalities' ), '

' . \esc_html( $docs['usage'] ) . '

', 'usage' ); + } + + if ( ! empty( $docs['hooks'] ) ) { + $hooks_html = '
'; + foreach ( $docs['hooks'] as $hook ) { + $hooks_html .= '
' . \esc_html( $hook['name'] ) . '
'; + $hooks_html .= '
' . \esc_html( $hook['description'] ) . '
'; + } + $hooks_html .= '
'; + Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); + } + } + + /** + * Detected assumptions field callback. + * + * @return void + */ + public static function field_detected_assumptions(): void { + $assumptions = \Functionalities\Features\Assumption_Detection::get_detected_assumptions(); + $ignored = \Functionalities\Features\Assumption_Detection::get_ignored_assumptions(); + + // Filter out expired ignored items and already-ignored assumptions. + $active_warnings = array(); + foreach ( $assumptions as $assumption ) { + $hash = self::generate_warning_hash( $assumption ); + // Skip if ignored and not expired. + if ( isset( $ignored[ $hash ] ) && $ignored[ $hash ]['expires'] > time() ) { + continue; + } + $assumption['_hash'] = $hash; + $active_warnings[] = $assumption; + } + + if ( empty( $active_warnings ) ) { + echo '
'; + echo ''; + echo '' . \esc_html__( 'No assumption changes detected. All monitored items are consistent.', 'functionalities' ) . ''; + echo '
'; + return; + } + + echo '
'; + + foreach ( $active_warnings as $assumption ) { + $hash = $assumption['_hash']; + $warning_type = $assumption['type'] ?? 'unknown'; + $type_class = 'warning'; + $icon = 'dashicons-warning'; + + echo '
'; + echo '
'; + echo ''; + echo '
'; + echo '

' . \esc_html( $assumption['message'] ?? '' ) . '

'; + + // Show location (where) if available. + if ( ! empty( $assumption['location'] ) ) { + echo '

'; + echo '' . \esc_html__( 'Where:', 'functionalities' ) . ' '; + echo wp_kses( + $assumption['location'], + array( + 'code' => array(), + 'strong' => array(), + ) + ); + echo '

'; + } + + // Show reason (why) if available. + if ( ! empty( $assumption['reason'] ) ) { + echo '

'; + echo '' . \esc_html__( 'Why it matters:', 'functionalities' ) . ' '; + echo \esc_html( $assumption['reason'] ); + echo '

'; + } + + // Show type badge. + echo '' . \esc_html( str_replace( '_', ' ', ucfirst( $warning_type ) ) ) . ''; + + if ( ! empty( $assumption['detected'] ) ) { + $time_ago = \human_time_diff( $assumption['detected'], \time() ); + /* translators: %s: human-readable time difference */ + echo '' . \sprintf( \esc_html__( 'Detected %s ago', 'functionalities' ), \esc_html( $time_ago ) ) . ''; + } + + echo '
'; + + // Actions. + echo '
'; + echo ''; + echo ''; + echo ''; + echo '
'; + + echo '
'; + } + + echo '
'; + + // Inline script for AJAX actions. + $nonce = \wp_create_nonce( 'functionalities_assumptions' ); + ?> + + false ) ); + + // Handle enable/disable toggle. + if ( isset( $_POST['functionalities_task_manager_toggle'] ) && \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'functionalities_task_manager_toggle' ) ) { + $tm_opts['enabled'] = ! empty( $_POST['enabled'] ); + \update_option( 'functionalities_task_manager', $tm_opts ); + echo '

' . \esc_html__( 'Settings saved.', 'functionalities' ) . '

'; + } + ?> +
+

+ + +

+ + + +
+ + + +
+ + +

+ +

+ + + + + + + +
+ + +
+

+ + +

+

+
+
+ +
+ +

+
+
+
+ +
+ +

+
+
+
+ +
+ +

+
+
+
+ +
+ +

+
+
+
+ +
+ +

+
+
+
+ +
+ +

+
+
+
+

+ + ' . \esc_html( WP_CONTENT_DIR . '/functionalities/tasks/' ) . '' + ); + ?> +

+
+ +
+ + + +
+ + +
+ +

+

+
+ +
+ $project ) : + $stats = \Functionalities\Features\Task_Manager::get_stats( $project ); + ?> +
+

+ + +

+
+ +
+
+
+
+ +
+ + +
+ +
+ + + + + +
+
+ +
+ + + +
+
+

+

+ +
+ + +
+
+
+ + +
+
+

+

+ +
+ + +
+
+
+ + + +
+
+

+ + + () + +

+
+
+
+
+
+
+ + +
+ + + +
+
+ +
+
+
+ + +
+ + + +
+ +
+
+ +
+
+
+ +
+ + +
+ +
+ +
+

+
+ + +
+
+ +
+ > +
+

+
+ + + ! + + + + # + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ !1 + 0 +
+
+
+
+
+ !2 + 0 +
+
+
+
+
+ !3 + 0 +
+
+
+
+
+ + 0 +
+
+
+
+ + +
+
+

+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+

+

+ +
+ + +
+
+
+ + + false ) ); + + // Handle enable/disable toggle. + if ( isset( $_POST['functionalities_redirect_manager_toggle'] ) && \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'functionalities_redirect_manager_toggle' ) ) { + $rm_opts['enabled'] = ! empty( $_POST['enabled'] ); + \update_option( 'functionalities_redirect_manager', $rm_opts ); + echo '

' . \esc_html__( 'Settings saved.', 'functionalities' ) . '

'; + } + if ( isset( $_POST['functionalities_redirect_monitor_settings'] ) && \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'functionalities_redirect_monitor_settings' ) ) { + $rm_opts['monitor_404'] = ! empty( $_POST['monitor_404'] ); + $rm_opts['monitor_cap'] = max( 25, min( 2000, (int) ( $_POST['monitor_cap'] ?? 500 ) ) ); + $rm_opts['monitor_retention_days'] = max( 1, min( 365, (int) ( $_POST['monitor_retention_days'] ?? 30 ) ) ); + $rm_opts['monitor_exclusions'] = \sanitize_textarea_field( \wp_unslash( $_POST['monitor_exclusions'] ?? '' ) ); + \update_option( 'functionalities_redirect_manager', $rm_opts ); + echo '

' . \esc_html__( '404 monitor settings saved.', 'functionalities' ) . '

'; + } + $not_found = \Functionalities\Features\Redirect_Manager::get_404_log(); + ?> +
+

+ + +

+ + + +
+ + + +
+ + +

+ +

+ + +
+

+

+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+ +
+

+
+ + + + +
+

+
+ +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
> + +
+
+ +
+ + + + +
+ +
+

+
+ + +

+

 

+

+ + +
+ + + + + + + + + +
+
+ + +
+ +

' . \esc_html__( 'Settings saved.', 'functionalities' ) . '

'; + } + ?> +
+

+ + +

+ + + + +
+ + + +
+ + +
+ '; + foreach ( $docs['features'] as $feature ) { + $list .= '
  • ' . \esc_html( $feature ) . '
  • '; + } + $list .= ''; + Admin_UI::render_docs_section( \__( 'What This Module Does', 'functionalities' ), $list, 'info' ); + } + if ( ! empty( $docs['usage'] ) ) { + Admin_UI::render_docs_section( \__( 'How to Use', 'functionalities' ), '

    ' . \esc_html( $docs['usage'] ) . '

    ', 'usage' ); + } + if ( ! empty( $docs['hooks'] ) ) { + $hooks_html = '
    '; + foreach ( $docs['hooks'] as $hook ) { + $hooks_html .= '
    ' . \esc_html( $hook['name'] ) . '
    '; + $hooks_html .= '
    ' . \esc_html( $hook['description'] ) . '
    '; + } + $hooks_html .= '
    '; + Admin_UI::render_docs_section( \__( 'Developer Hooks', 'functionalities' ), $hooks_html, 'developer' ); + } + ?> +
    + + +
    +

    +
    + + +

    +
    +
    + + +
    +
    + + +

    +
    +
    + +
    +
    + + +
    +
    + + +

    ()

    + + +
    + +

    +
    + +
    + $icon ) : ?> +
    +
    + +
    +
    +

    +

    +
    +
    + + +
    +
    + +
    + +
    + + + + + true, + 'class' => true, + 'style' => true, + 'fill' => true, + 'fill-opacity' => true, + 'fill-rule' => true, + 'stroke' => true, + 'stroke-width' => true, + 'stroke-linecap' => true, + 'stroke-linejoin' => true, + 'stroke-dasharray' => true, + 'stroke-dashoffset' => true, + 'stroke-opacity' => true, + 'opacity' => true, + 'transform' => true, + 'clip-path' => true, + 'clip-rule' => true, + 'mask' => true, + ); + + return array( + 'svg' => array_merge( + $common_attrs, + array( + 'xmlns' => true, + 'xmlns:xlink' => true, + 'viewbox' => true, + 'width' => true, + 'height' => true, + 'aria-hidden' => true, + 'role' => true, + 'focusable' => true, + 'preserveaspectratio' => true, + 'version' => true, + 'xml:space' => true, + 'enable-background' => true, + ) + ), + 'g' => $common_attrs, + 'path' => array_merge( $common_attrs, array( 'd' => true ) ), + 'circle' => array_merge( + $common_attrs, + array( + 'cx' => true, + 'cy' => true, + 'r' => true, + ) + ), + 'ellipse' => array_merge( + $common_attrs, + array( + 'cx' => true, + 'cy' => true, + 'rx' => true, + 'ry' => true, + ) + ), + 'rect' => array_merge( + $common_attrs, + array( + 'x' => true, + 'y' => true, + 'width' => true, + 'height' => true, + 'rx' => true, + 'ry' => true, + ) + ), + 'line' => array_merge( + $common_attrs, + array( + 'x1' => true, + 'y1' => true, + 'x2' => true, + 'y2' => true, + ) + ), + 'polyline' => array_merge( $common_attrs, array( 'points' => true ) ), + 'polygon' => array_merge( $common_attrs, array( 'points' => true ) ), + 'defs' => array( 'id' => true ), + 'clippath' => array( 'id' => true ), + 'mask' => array( 'id' => true ), + 'use' => array( + 'xlink:href' => true, + 'href' => true, + 'x' => true, + 'y' => true, + 'width' => true, + 'height' => true, + ), + 'symbol' => array( + 'id' => true, + 'viewbox' => true, + 'preserveaspectratio' => true, + ), + 'title' => array(), + 'desc' => array(), + 'lineargradient' => array( + 'id' => true, + 'gradientunits' => true, + 'gradienttransform' => true, + 'spreadmethod' => true, + 'x1' => true, + 'y1' => true, + 'x2' => true, + 'y2' => true, + ), + 'radialgradient' => array( + 'id' => true, + 'gradientunits' => true, + 'gradienttransform' => true, + 'spreadmethod' => true, + 'cx' => true, + 'cy' => true, + 'r' => true, + 'fx' => true, + 'fy' => true, + ), + 'stop' => array( + 'offset' => true, + 'stop-color' => true, + 'stop-opacity' => true, + 'style' => true, + ), + ); + } + + // ------------------------------------------------------------------------- + // PWA Field Renderers + // ------------------------------------------------------------------------- + + /** + * Render a media upload field. + * + * @param string $name Input name attribute. + * @param string $value Current URL value. + * @param string $desc Description text. + * @return void + */ + private static function render_media_field( string $name, string $value, string $desc = '' ): void { + $id = 'func-media-' . \sanitize_key( str_replace( array( '[', ']' ), '-', $name ) ); + echo '
    '; + echo ''; + echo ' '; + if ( $value ) { + echo '
    '; + } + if ( $desc ) { + echo '

    ' . \esc_html( $desc ) . '

    '; + } + echo '
    '; + } + + /** + * Render snippets repeater field for a given location. + * + * @since 1.4.0 + * + * @param string $location Location key: header, body_open, or footer. + * @param string $hook_name WordPress hook name for description. + * @return void + */ + public static function field_snippets_repeater( string $location, string $hook_name ): void { + $o = self::get_snippets_options(); + $items = ! empty( $o[ $location ] ) && \is_array( $o[ $location ] ) ? $o[ $location ] : array(); + $cid = 'func-snippets-' . $location; + + echo '
    '; + $i = 0; + foreach ( $items as $item ) { + self::render_snippet_row( $location, $i, $item ); + ++$i; + } + echo '
    '; + + echo ''; + echo '

    ' . \sprintf( + /* translators: %s: WordPress hook name */ + \esc_html__( 'Output in %s. Each snippet can be individually toggled.', 'functionalities' ), + '' . \esc_html( $hook_name ) . '' + ) . '

    '; + + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inline JS for repeater template, encoded via wp_json_encode. + echo ''; + } + + /** + * Render a single snippet repeater row. + * + * @since 1.4.0 + * + * @param string $location Location key. + * @param int $index Row index. + * @param array $data Snippet data (label, code, enabled). + * @return void + */ + private static function render_snippet_row( string $location, int $index, array $data ): void { + $label = $data['label'] ?? ''; + $code = $data['code'] ?? ''; + $enabled = ! empty( $data['enabled'] ); + $prefix = 'functionalities_snippets[' . $location . '][' . $index . ']'; + $has_code = '' !== $code; + + echo '
    '; + + // Header bar — always visible. + echo '
    '; + echo ''; + echo ''; + if ( $has_code ) { + echo '' . \esc_html( self::snippet_type_badge( $code ) ) . ''; + } + echo ''; + echo ''; + echo '
    '; + + // Body — collapsible. + echo '
    '; + echo ''; + echo '
    '; + + echo '
    '; + } + + /** + * Detect snippet type from code for badge display. + * + * @since 1.4.0 + * + * @param string $code Snippet code. + * @return string Badge label. + */ + private static function snippet_type_badge( string $code ): string { + $code_lower = strtolower( ltrim( $code ) ); + if ( 0 === strpos( $code_lower, ' true ) ); + $html = ob_get_clean(); + return str_replace( (string) $sentinel, $index, $html ); + } + + /** + * Render PWA shortcuts repeater field. + * + * @return void + */ + public static function field_pwa_shortcuts(): void { + $o = self::get_pwa_options(); + $shortcuts = ! empty( $o['shortcuts'] ) ? $o['shortcuts'] : array(); + echo '
    '; + $i = 0; + foreach ( $shortcuts as $sc ) { + self::render_shortcut_row( $i, $sc ); + ++$i; + } + echo '
    '; + echo ''; + echo '

    ' . \esc_html__( 'Maximum 4 shortcuts. Each needs a name and URL.', 'functionalities' ) . '

    '; + echo ''; + } + + /** + * Render a single shortcut row. + * + * @param int $index Row index. + * @param array $data Shortcut data. + * @return void + */ + private static function render_shortcut_row( int $index, array $data ): void { + $name = \esc_attr( $data['name'] ?? '' ); + $url = \esc_attr( $data['url'] ?? '' ); + $desc = \esc_attr( $data['description'] ?? '' ); + $icon = \esc_attr( $data['icon'] ?? '' ); + $prefix = 'functionalities_pwa[shortcuts][' . $index . ']'; + echo '
    '; + echo '
    '; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
    '; + echo ''; + echo '
    '; + } + + /** + * Get shortcut row HTML template for JS. + * + * @param string $index Placeholder index. + * @return string HTML template. + */ + private static function get_shortcut_row_html( string $index ): string { + ob_start(); + self::render_shortcut_row( (int) $index, array() ); + $html = ob_get_clean(); + return str_replace( (string) (int) $index, $index, $html ); + } + + /** + * Render PWA screenshots repeater field. + * + * @return void + */ + public static function field_pwa_screenshots(): void { + $o = self::get_pwa_options(); + $screenshots = ! empty( $o['screenshots'] ) ? $o['screenshots'] : array(); + echo '
    '; + $i = 0; + foreach ( $screenshots as $ss ) { + self::render_screenshot_row( $i, $ss ); + ++$i; + } + echo '
    '; + echo ''; + echo ''; + } + + /** + * Render a single screenshot row. + * + * @param int $index Row index. + * @param array $data Screenshot data. + * @return void + */ + private static function render_screenshot_row( int $index, array $data ): void { + $src = \esc_attr( $data['src'] ?? '' ); + $sizes = \esc_attr( $data['sizes'] ?? '' ); + $label = \esc_attr( $data['label'] ?? '' ); + $form = $data['form_factor'] ?? 'wide'; + $prefix = 'functionalities_pwa[screenshots][' . $index . ']'; + echo '
    '; + echo '
    '; + echo ''; + echo ''; + echo ''; + echo ''; + echo '
    '; + echo ''; + echo '
    '; + } + + /** + * Get screenshot row HTML template for JS. + * + * @param string $index Placeholder index. + * @return string HTML template. + */ + private static function get_screenshot_row_html( string $index ): string { + ob_start(); + self::render_screenshot_row( (int) $index, array() ); + $html = ob_get_clean(); + return str_replace( (string) (int) $index, $index, $html ); + } +} diff --git a/includes/admin/class-module-docs.php b/includes/admin/class-module-docs.php index 6aab652..4a012e4 100644 --- a/includes/admin/class-module-docs.php +++ b/includes/admin/class-module-docs.php @@ -24,7 +24,7 @@ class Module_Docs { * @param string $module Module slug. * @return array Documentation configuration. */ - public static function get( string $module ) : array { + public static function get( string $module ): array { $docs = self::get_all(); return $docs[ $module ] ?? array(); } @@ -34,112 +34,181 @@ public static function get( string $module ) : array { * * @return array All module docs. */ - public static function get_all() : array { + public static function get_all(): array { return array( - 'link-management' => array( + 'link-management' => array( 'features' => array( \__( 'Automatically adds rel="nofollow" to external links in post content', 'functionalities' ), \__( 'Opens external links in new tabs with proper security attributes', 'functionalities' ), \__( 'Whitelist trusted domains that should not get nofollow', 'functionalities' ), \__( 'Bulk update existing links in your database', 'functionalities' ), ), - 'hooks' => array( - array( 'name' => 'functionalities_nofollow_exceptions', 'description' => \__( 'Modify exception list', 'functionalities' ) ), - array( 'name' => 'functionalities_link_attributes', 'description' => \__( 'Modify link attributes', 'functionalities' ) ), - array( 'name' => 'functionalities_process_links', 'description' => \__( 'Toggle link processing', 'functionalities' ) ), + 'hooks' => array( + array( + 'name' => 'functionalities_nofollow_exceptions', + 'description' => \__( 'Modify exception list', 'functionalities' ), + ), + array( + 'name' => 'functionalities_link_attributes', + 'description' => \__( 'Modify link attributes', 'functionalities' ), + ), + array( + 'name' => 'functionalities_process_links', + 'description' => \__( 'Toggle link processing', 'functionalities' ), + ), ), ), - 'block-cleanup' => array( + 'block-cleanup' => array( 'features' => array( \__( 'Reduces HTML bloat by removing unnecessary block classes', 'functionalities' ), \__( 'Useful when your theme provides custom styling for headings, lists, images', 'functionalities' ), \__( 'Only affects frontend output, block editor remains unchanged', 'functionalities' ), ), - 'hooks' => array( - array( 'name' => 'functionalities_block_cleanup_enabled', 'description' => \__( 'Toggle cleanup globally', 'functionalities' ) ), - array( 'name' => 'functionalities_block_cleanup_classes', 'description' => \__( 'Modify classes to remove', 'functionalities' ) ), + 'hooks' => array( + array( + 'name' => 'functionalities_block_cleanup_enabled', + 'description' => \__( 'Toggle cleanup globally', 'functionalities' ), + ), + array( + 'name' => 'functionalities_block_cleanup_classes', + 'description' => \__( 'Modify classes to remove', 'functionalities' ), + ), ), ), - 'editor-links' => array( + 'editor-links' => array( 'features' => array( \__( 'Limits link search results to specific post types when inserting links in Gutenberg', 'functionalities' ), \__( 'Reduces clutter by hiding unwanted content types from search results', 'functionalities' ), \__( 'Works with posts, pages, and custom post types', 'functionalities' ), ), - 'usage' => \__( 'Enable limitation, then check only the post types you want to appear when searching for links in the editor. Unchecked post types will be hidden from link search results.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_editor_links_enabled', 'description' => \__( 'Toggle feature', 'functionalities' ) ), - array( 'name' => 'functionalities_editor_links_post_types', 'description' => \__( 'Modify allowed post types', 'functionalities' ) ), + 'usage' => \__( 'Enable limitation, then check only the post types you want to appear when searching for links in the editor. Unchecked post types will be hidden from link search results.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_editor_links_enabled', + 'description' => \__( 'Toggle feature', 'functionalities' ), + ), + array( + 'name' => 'functionalities_editor_links_post_types', + 'description' => \__( 'Modify allowed post types', 'functionalities' ), + ), ), ), - 'snippets' => array( + 'snippets' => array( 'features' => array( \__( 'Native Google Analytics 4 integration - just enter your Measurement ID', 'functionalities' ), \__( 'Custom header code for meta tags, scripts, styles, and tracking codes', 'functionalities' ), \__( 'Custom footer code for chat widgets, tracking pixels, and deferred scripts', 'functionalities' ), \__( 'Automatically skips admin pages, feeds, and REST API requests', 'functionalities' ), ), - 'usage' => \__( 'Allowed tags: script, style, link, meta, noscript.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_snippets_output_enabled', 'description' => \__( 'Disable on specific pages', 'functionalities' ) ), - array( 'name' => 'functionalities_snippets_ga4_enabled', 'description' => \__( 'Control GA4 per user/page', 'functionalities' ) ), - array( 'name' => 'functionalities_snippets_header_code', 'description' => \__( 'Modify header code', 'functionalities' ) ), - array( 'name' => 'functionalities_snippets_footer_code', 'description' => \__( 'Modify footer code', 'functionalities' ) ), + 'usage' => \__( 'Allowed tags: script, style, link, meta, noscript.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_snippets_output_enabled', + 'description' => \__( 'Disable on specific pages', 'functionalities' ), + ), + array( + 'name' => 'functionalities_snippets_ga4_enabled', + 'description' => \__( 'Control GA4 per user/page', 'functionalities' ), + ), + array( + 'name' => 'functionalities_snippets_header_code', + 'description' => \__( 'Modify header code', 'functionalities' ), + ), + array( + 'name' => 'functionalities_snippets_footer_code', + 'description' => \__( 'Modify footer code', 'functionalities' ), + ), ), ), - 'schema' => array( + 'schema' => array( 'features' => array( \__( 'Adds itemscope/itemtype to the HTML element for page-level schema', 'functionalities' ), \__( 'Wraps article content with Article/BlogPosting microdata', 'functionalities' ), \__( 'Adds structured data for headlines, dates, and authors', 'functionalities' ), \__( 'Marks header and footer regions with WPHeader/WPFooter types', 'functionalities' ), ), - 'usage' => \__( 'Site types: WebPage, AboutPage, ContactPage, Blog, SearchResultsPage. Article types: Article, BlogPosting, NewsArticle.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_schema_enabled', 'description' => \__( 'Toggle all schema output', 'functionalities' ) ), - array( 'name' => 'functionalities_schema_site_type', 'description' => \__( 'Modify site itemtype', 'functionalities' ) ), - array( 'name' => 'functionalities_schema_article_type', 'description' => \__( 'Modify article itemtype', 'functionalities' ) ), - array( 'name' => 'functionalities_schema_content', 'description' => \__( 'Modify wrapped content', 'functionalities' ) ), + 'usage' => \__( 'Site types: WebPage, AboutPage, ContactPage, Blog, SearchResultsPage. Article types: Article, BlogPosting, NewsArticle.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_schema_enabled', + 'description' => \__( 'Toggle all schema output', 'functionalities' ), + ), + array( + 'name' => 'functionalities_schema_site_type', + 'description' => \__( 'Modify site itemtype', 'functionalities' ), + ), + array( + 'name' => 'functionalities_schema_article_type', + 'description' => \__( 'Modify article itemtype', 'functionalities' ), + ), + array( + 'name' => 'functionalities_schema_content', + 'description' => \__( 'Modify wrapped content', 'functionalities' ), + ), ), ), - 'components' => array( + 'components' => array( 'features' => array( \__( 'Define CSS class names and their style rules in one place', 'functionalities' ), \__( 'Components are compiled into a single CSS file for optimal caching', 'functionalities' ), \__( 'Available on both frontend and admin pages', 'functionalities' ), \__( 'Includes default utility components like visually-hidden, skip-link, and marquee', 'functionalities' ), ), - 'usage' => \__( 'Add components by entering a CSS selector (e.g., .my-button) and CSS rules (e.g., background: blue; color: white;). Use the grid below to manage your components.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_components_enabled', 'description' => \__( 'Toggle output', 'functionalities' ) ), - array( 'name' => 'functionalities_components_items', 'description' => \__( 'Add components dynamically', 'functionalities' ) ), - array( 'name' => 'functionalities_components_css', 'description' => \__( 'Modify generated CSS', 'functionalities' ) ), - array( 'name' => 'functionalities_components_updated', 'description' => \__( 'Action: fires when CSS file regenerates', 'functionalities' ) ), + 'usage' => \__( 'Add components by entering a CSS selector (e.g., .my-button) and CSS rules (e.g., background: blue; color: white;). Use the grid below to manage your components.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_components_enabled', + 'description' => \__( 'Toggle output', 'functionalities' ), + ), + array( + 'name' => 'functionalities_components_items', + 'description' => \__( 'Add components dynamically', 'functionalities' ), + ), + array( + 'name' => 'functionalities_components_css', + 'description' => \__( 'Modify generated CSS', 'functionalities' ), + ), + array( + 'name' => 'functionalities_components_updated', + 'description' => \__( 'Action: fires when CSS file regenerates', 'functionalities' ), + ), ), ), - 'fonts' => array( + 'fonts' => array( 'features' => array( \__( 'Generate @font-face CSS rules for self-hosted fonts', 'functionalities' ), \__( 'Support for variable fonts with weight ranges (e.g., 100 900)', 'functionalities' ), \__( 'WOFF2 format for modern browsers, optional WOFF fallback', 'functionalities' ), \__( 'Configurable font-display strategy (swap, auto, block, etc.)', 'functionalities' ), ), - 'usage' => \__( 'Upload font files to your media library or server, then add font entries below with the family name and file URLs. Use the generated font-family name in your CSS.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_fonts_enabled', 'description' => \__( 'Toggle output', 'functionalities' ) ), - array( 'name' => 'functionalities_fonts_items', 'description' => \__( 'Add fonts dynamically', 'functionalities' ) ), - array( 'name' => 'functionalities_fonts_css', 'description' => \__( 'Modify generated CSS', 'functionalities' ) ), - array( 'name' => 'functionalities_fonts_before_output', 'description' => \__( 'Action: before output', 'functionalities' ) ), + 'usage' => \__( 'Upload font files to your media library or server, then add font entries below with the family name and file URLs. Use the generated font-family name in your CSS.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_fonts_enabled', + 'description' => \__( 'Toggle output', 'functionalities' ), + ), + array( + 'name' => 'functionalities_fonts_items', + 'description' => \__( 'Add fonts dynamically', 'functionalities' ), + ), + array( + 'name' => 'functionalities_fonts_css', + 'description' => \__( 'Modify generated CSS', 'functionalities' ), + ), + array( + 'name' => 'functionalities_fonts_before_output', + 'description' => \__( 'Action: before output', 'functionalities' ), + ), ), ), - 'svg-icons' => array( + 'svg-icons' => array( 'features' => array( \__( 'Upload or paste custom SVG icons with unique namespaces', 'functionalities' ), \__( 'Insert icons inline in the block editor via RichText toolbar', 'functionalities' ), @@ -148,56 +217,95 @@ public static function get_all() : array { \__( 'Zero frontend footprint - icons only render where used', 'functionalities' ), \__( 'Also available via shortcode: [func_icon name="icon-slug"]', 'functionalities' ), ), - 'usage' => \__( 'Add icons by pasting SVG code in the admin. Each icon gets a unique slug. In the block editor, use the icon button in the toolbar to insert icons inline. Icons will scale with the surrounding text.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_svg_icons_enabled', 'description' => \__( 'Toggle feature on/off', 'functionalities' ) ), - array( 'name' => 'functionalities_svg_icons_list', 'description' => \__( 'Filter available icons', 'functionalities' ) ), - array( 'name' => 'functionalities_svg_icons_sanitize', 'description' => \__( 'Modify SVG before saving', 'functionalities' ) ), + 'usage' => \__( 'Add icons by pasting SVG code in the admin. Each icon gets a unique slug. In the block editor, use the icon button in the toolbar to insert icons inline. Icons will scale with the surrounding text.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_svg_icons_enabled', + 'description' => \__( 'Toggle feature on/off', 'functionalities' ), + ), + array( + 'name' => 'functionalities_svg_icons_list', + 'description' => \__( 'Filter available icons', 'functionalities' ), + ), + array( + 'name' => 'functionalities_svg_icons_sanitize', + 'description' => \__( 'Modify SVG before saving', 'functionalities' ), + ), ), ), - 'misc' => array( + 'misc' => array( 'features' => array( \__( 'Remove bloat like emojis, oEmbeds, and unnecessary meta tags', 'functionalities' ), \__( 'Disable security concerns like XML-RPC and version disclosure', 'functionalities' ), \__( 'Improve performance by removing unused scripts and styles', 'functionalities' ), \__( 'Add useful enhancements like PrismJS and fullscreen textareas', 'functionalities' ), ), - 'caution' => \__( 'Some options may break functionality if plugins depend on them. Test after enabling. Disable Heartbeat API with care if you use auto-save or real-time features.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_misc_options', 'description' => \__( 'Modify options before application', 'functionalities' ) ), - array( 'name' => 'functionalities_misc_disable_emojis', 'description' => \__( 'Control emoji removal', 'functionalities' ) ), - array( 'name' => 'functionalities_misc_disable_embeds', 'description' => \__( 'Control embed removal', 'functionalities' ) ), + 'caution' => \__( 'Some options may break functionality if plugins depend on them. Test after enabling. Disable Heartbeat API with care if you use auto-save or real-time features.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_misc_options', + 'description' => \__( 'Modify options before application', 'functionalities' ), + ), + array( + 'name' => 'functionalities_misc_disable_emojis', + 'description' => \__( 'Control emoji removal', 'functionalities' ), + ), + array( + 'name' => 'functionalities_misc_disable_embeds', + 'description' => \__( 'Control embed removal', 'functionalities' ), + ), ), ), - 'meta' => array( + 'meta' => array( 'features' => array( \__( 'Adds copyright meta tags with configurable holder', 'functionalities' ), \__( 'Dublin Core (DCMI) metadata support', 'functionalities' ), \__( 'Per-post license selection metabox', 'functionalities' ), \__( 'Integration with popular SEO plugins for Schema.org output', 'functionalities' ), ), - 'hooks' => array( - array( 'name' => 'functionalities_meta_enabled', 'description' => \__( 'Toggle module', 'functionalities' ) ), - array( 'name' => 'functionalities_meta_copyright_holder', 'description' => \__( 'Modify copyright holder', 'functionalities' ) ), - array( 'name' => 'functionalities_meta_license', 'description' => \__( 'Modify license output', 'functionalities' ) ), + 'hooks' => array( + array( + 'name' => 'functionalities_meta_enabled', + 'description' => \__( 'Toggle module', 'functionalities' ), + ), + array( + 'name' => 'functionalities_meta_copyright_holder', + 'description' => \__( 'Modify copyright holder', 'functionalities' ), + ), + array( + 'name' => 'functionalities_meta_license', + 'description' => \__( 'Modify license output', 'functionalities' ), + ), ), ), - 'content-regression' => array( + 'content-regression' => array( 'features' => array( \__( 'Detects when internal links are accidentally removed', 'functionalities' ), \__( 'Warns when content is shortened significantly', 'functionalities' ), \__( 'Checks heading structure for accessibility issues', 'functionalities' ), \__( 'Stores snapshots to compare against historical baselines', 'functionalities' ), ), - 'usage' => \__( 'This is content integrity, not SEO. The system answers: "Did this update accidentally damage something important?" It never scores content, never suggests fixes, and always compares a post to itself.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_regression_enabled', 'description' => \__( 'Toggle detection', 'functionalities' ) ), - array( 'name' => 'functionalities_regression_post_types', 'description' => \__( 'Modify enabled post types', 'functionalities' ) ), - array( 'name' => 'functionalities_regression_warnings', 'description' => \__( 'Modify detected warnings', 'functionalities' ) ), - array( 'name' => 'functionalities_regression_snapshot_saved', 'description' => \__( 'Action: after snapshot saved', 'functionalities' ) ), + 'usage' => \__( 'This is content integrity, not SEO. The system answers: "Did this update accidentally damage something important?" It never scores content, never suggests fixes, and always compares a post to itself.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_regression_enabled', + 'description' => \__( 'Toggle detection', 'functionalities' ), + ), + array( + 'name' => 'functionalities_regression_post_types', + 'description' => \__( 'Modify enabled post types', 'functionalities' ), + ), + array( + 'name' => 'functionalities_regression_warnings', + 'description' => \__( 'Modify detected warnings', 'functionalities' ), + ), + array( + 'name' => 'functionalities_regression_snapshot_saved', + 'description' => \__( 'Action: after snapshot saved', 'functionalities' ), + ), ), ), @@ -208,15 +316,24 @@ public static function get_all() : array { \__( 'Notices when fonts are loaded from redundant sources', 'functionalities' ), \__( 'Tracks inline CSS growth over time', 'functionalities' ), ), - 'usage' => \__( 'This module notices when assumptions stop being true. It does not optimize, does not enforce best practices, and does not recommend plugins. It simply says: "This used to be true. Now it isn\'t."', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_assumptions_enabled', 'description' => \__( 'Toggle detection', 'functionalities' ) ), - array( 'name' => 'functionalities_assumptions_detectors', 'description' => \__( 'Modify active detectors', 'functionalities' ) ), - array( 'name' => 'functionalities_assumptions_warnings', 'description' => \__( 'Modify detected warnings', 'functionalities' ) ), + 'usage' => \__( 'This module notices when assumptions stop being true. It does not optimize, does not enforce best practices, and does not recommend plugins. It simply says: "This used to be true. Now it isn\'t."', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_assumptions_enabled', + 'description' => \__( 'Toggle detection', 'functionalities' ), + ), + array( + 'name' => 'functionalities_assumptions_detectors', + 'description' => \__( 'Modify active detectors', 'functionalities' ), + ), + array( + 'name' => 'functionalities_assumptions_warnings', + 'description' => \__( 'Modify detected warnings', 'functionalities' ), + ), ), ), - 'pwa' => array( + 'pwa' => array( 'features' => array( \__( 'Web App Manifest for installability on mobile and desktop', 'functionalities' ), \__( 'Service worker with cache-first, network-first, and stale-while-revalidate strategies', 'functionalities' ), @@ -224,11 +341,20 @@ public static function get_all() : array { \__( 'Customizable install prompt with position and style options', 'functionalities' ), \__( 'App shortcuts, screenshots, share target, and display override support', 'functionalities' ), ), - 'usage' => \__( 'Enable this module to make your site installable as a standalone app. Configure icons, colors, and caching behavior. Visitors can install it from the browser or a custom prompt. The service worker handles offline access and smart caching.', 'functionalities' ), - 'hooks' => array( - array( 'name' => 'functionalities_pwa_enabled', 'description' => \__( 'Toggle PWA output', 'functionalities' ) ), - array( 'name' => 'functionalities_pwa_manifest', 'description' => \__( 'Filter manifest data before output', 'functionalities' ) ), - array( 'name' => 'functionalities_pwa_service_worker_config', 'description' => \__( 'Filter service worker configuration', 'functionalities' ) ), + 'usage' => \__( 'Enable this module to make your site installable as a standalone app. Configure icons, colors, and caching behavior. Visitors can install it from the browser or a custom prompt. The service worker handles offline access and smart caching.', 'functionalities' ), + 'hooks' => array( + array( + 'name' => 'functionalities_pwa_enabled', + 'description' => \__( 'Toggle PWA output', 'functionalities' ), + ), + array( + 'name' => 'functionalities_pwa_manifest', + 'description' => \__( 'Filter manifest data before output', 'functionalities' ), + ), + array( + 'name' => 'functionalities_pwa_service_worker_config', + 'description' => \__( 'Filter service worker configuration', 'functionalities' ), + ), ), ), ); diff --git a/includes/admin/class-redirect-manager-controller.php b/includes/admin/class-redirect-manager-controller.php new file mode 100644 index 0000000..e1ab2e6 --- /dev/null +++ b/includes/admin/class-redirect-manager-controller.php @@ -0,0 +1,27 @@ + \admin_url( 'admin-ajax.php' ), + 'nonce' => \wp_create_nonce( self::NONCE_ACTION ), + ) + ); + } + + /** + * Render settings portability controls on the dashboard. + * + * @return void + */ + public static function render_panel(): void { + $modules = \Functionalities\Core\Module_Registry::get_definitions(); + ?> +
    +

    +

    +
    + +
    + $definition ) : ?> + + +
    +
    + +
    + + + + + +
    + +
    + self::SCHEMA_VERSION, + 'plugin' => 'dynamic-functionalities', + 'pluginVersion' => FUNCTIONALITIES_VERSION, + 'exportedAt' => gmdate( 'c' ), + 'settings' => $settings, + 'redacted' => $redacted, + ); + } + + /** + * Validate and compare an import document without changing options. + * + * @param array $document Import document. + * @param bool $include_code Permit custom code fields. + * @return array + */ + public static function preview_import( array $document, bool $include_code = false ): array { + if ( ! isset( $document['schema'] ) ) { + $document['schema'] = 1; + } + if ( self::SCHEMA_VERSION !== (int) $document['schema'] ) { + return array( + 'success' => false, + 'error' => 'unsupported_schema', + ); + } + if ( 'dynamic-functionalities' !== ( $document['plugin'] ?? '' ) || ! isset( $document['settings'] ) || ! is_array( $document['settings'] ) ) { + return array( + 'success' => false, + 'error' => 'invalid_document', + ); + } + + $definitions = \Functionalities\Core\Module_Registry::get_definitions(); + $validated = array(); + $changes = array(); + $skipped = array(); + + foreach ( $document['settings'] as $slug => $incoming ) { + if ( ! isset( $definitions[ $slug ] ) || ! is_array( $incoming ) ) { + $skipped[ $slug ] = 'unknown_or_invalid_module'; + continue; + } + if ( ! $include_code ) { + list( $incoming, $removed ) = self::redact_custom_code( $slug, $incoming ); + if ( $removed ) { + $skipped[ $slug ] = $removed; + } + } + + $current = (array) \get_option( $definitions[ $slug ]['option'], array() ); + $incoming = self::validate_module( $slug, $incoming, $include_code ); + $incoming = array_merge( $current, $incoming ); + $validated[ $slug ] = $incoming; + $changed_fields = array(); + foreach ( array_unique( array_merge( array_keys( $incoming ), array_keys( $current ) ) ) as $field ) { + if ( ! array_key_exists( $field, $incoming ) || ! array_key_exists( $field, $current ) || $incoming[ $field ] !== $current[ $field ] ) { + $changed_fields[] = $field; + } + } + $changes[ $slug ] = array( + 'status' => empty( $current ) ? 'add' : ( $current === $incoming ? 'unchanged' : 'change' ), + 'changed' => $changed_fields, + ); + } + + return array( + 'success' => true, + 'validated' => $validated, + 'changes' => $changes, + 'skipped' => $skipped, + ); + } + + /** + * Handle settings export. + * + * @return void + */ + public static function ajax_export(): void { + self::verify_request(); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by verify_request(). + $modules = isset( $_POST['modules'] ) ? array_map( 'sanitize_key', (array) \wp_unslash( $_POST['modules'] ) ) : array(); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by verify_request(). + $data = self::build_export( $modules, ! empty( $_POST['include_code'] ) ); + \wp_send_json_success( + array( + 'filename' => 'functionalities-settings-' . gmdate( 'Y-m-d' ) . '.json', + 'content' => \wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), + ) + ); + } + + /** + * Handle import preview. + * + * @return void + */ + public static function ajax_preview(): void { + self::verify_request(); + $document = self::decode_request_document(); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by verify_request(). + $preview = self::preview_import( $document, ! empty( $_POST['include_code'] ) ); + if ( ! $preview['success'] ) { + \wp_send_json_error( array( 'message' => $preview['error'] ) ); + } + unset( $preview['validated'] ); + \wp_send_json_success( $preview ); + } + + /** + * Apply a fully validated import. + * + * @return void + */ + public static function ajax_import(): void { + self::verify_request(); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by verify_request(). + $preview = self::preview_import( self::decode_request_document(), ! empty( $_POST['include_code'] ) ); + if ( ! $preview['success'] ) { + \wp_send_json_error( array( 'message' => $preview['error'] ) ); + } + + $definitions = \Functionalities\Core\Module_Registry::get_definitions(); + $updated = array(); + $originals = array(); + foreach ( $preview['validated'] as $slug => $value ) { + $option = $definitions[ $slug ]['option']; + $old = (array) \get_option( $option, array() ); + if ( $old === $value ) { + continue; + } + $originals[ $option ] = $old; + if ( ! \update_option( $option, $value ) ) { + foreach ( $originals as $rollback_option => $rollback_value ) { + \update_option( $rollback_option, $rollback_value ); + } + \wp_send_json_error( array( 'message' => \__( 'Import failed and previous settings were restored.', 'functionalities' ) ) ); + } + $updated[] = $slug; + } + \wp_send_json_success( + array( + 'updated' => $updated, + 'skipped' => $preview['skipped'], + ) + ); + } + + /** + * Download privacy-conscious environment diagnostics. + * + * @return void + */ + public static function ajax_diagnostics(): void { + self::verify_request(); + $enabled = array(); + foreach ( \Functionalities\Core\Module_Registry::get_definitions() as $slug => $definition ) { + if ( \Functionalities\Core\Module_Registry::is_enabled( $slug ) ) { + $enabled[] = $slug; + } + } + $upload = \wp_upload_dir(); + $data = array( + 'schema' => 1, + 'generatedAt' => gmdate( 'c' ), + 'pluginVersion' => FUNCTIONALITIES_VERSION, + 'wordpressVersion' => isset( $GLOBALS['wp_version'] ) ? $GLOBALS['wp_version'] : '', + 'phpVersion' => PHP_VERSION, + 'enabledModules' => $enabled, + 'paths' => array( + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Diagnostics is a read-only native-path check. + 'uploadsWritable' => empty( $upload['error'] ) && is_writable( $upload['basedir'] ), + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Diagnostics is a read-only native-path check. + 'dataWritable' => is_dir( WP_CONTENT_DIR . '/functionalities' ) ? is_writable( WP_CONTENT_DIR . '/functionalities' ) : is_writable( WP_CONTENT_DIR ), + ), + 'rewriteRules' => is_array( \get_option( 'rewrite_rules', array() ) ) && ! empty( \get_option( 'rewrite_rules', array() ) ) ? 'present' : 'missing', + ); + \wp_send_json_success( + array( + 'filename' => 'functionalities-diagnostics-' . gmdate( 'Y-m-d' ) . '.json', + 'content' => \wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), + ) + ); + } + + /** + * Verify capability and nonce. + * + * @return void + */ + private static function verify_request(): void { + \check_ajax_referer( self::NONCE_ACTION, 'nonce' ); + if ( ! \current_user_can( 'manage_options' ) ) { + \wp_send_json_error( array( 'message' => \__( 'Insufficient permissions.', 'functionalities' ) ), 403 ); + } + } + + /** + * Decode the posted JSON document. + * + * @return array + */ + private static function decode_request_document(): array { + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified by caller; JSON is validated after decoding. + $json = isset( $_POST['document'] ) ? \wp_unslash( $_POST['document'] ) : ''; + $data = json_decode( $json, true ); + if ( ! is_array( $data ) || JSON_ERROR_NONE !== json_last_error() ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid JSON document.', 'functionalities' ) ) ); + } + return $data; + } + + /** + * Remove custom code fields unless explicitly requested. + * + * @param string $slug Module slug. + * @param array $value Module settings. + * @return array + */ + private static function redact_custom_code( string $slug, array $value ): array { + $fields = array(); + $removed = array(); + if ( 'snippets' === $slug ) { + $fields = array_diff( array_keys( $value ), array( 'enabled' ) ); + } elseif ( 'components' === $slug || 'svg-icons' === $slug ) { + $fields = array( 'items' ); + if ( 'svg-icons' === $slug ) { + $fields = array( 'icons' ); + } + } + foreach ( $fields as $field ) { + if ( array_key_exists( $field, $value ) ) { + unset( $value[ $field ] ); + $removed[] = $field; + } + } + return array( $value, $removed ); + } + + /** + * Validate settings through the owning module sanitizer when available. + * + * @param string $slug Module slug. + * @param array $value Candidate settings. + * @param bool $include_code Whether custom code was explicitly selected. + * @return array + */ + private static function validate_module( string $slug, array $value, bool $include_code ): array { + $sanitizers = array( + 'link-management' => 'sanitize_link_management', + 'block-cleanup' => 'sanitize_block_cleanup', + 'editor-links' => 'sanitize_editor_links', + 'snippets' => 'sanitize_snippets', + 'schema' => 'sanitize_schema', + 'components' => 'sanitize_components', + 'misc' => 'sanitize_misc', + 'fonts' => 'sanitize_fonts', + 'login-security' => 'sanitize_login_security', + 'meta' => 'sanitize_meta', + 'content-regression' => 'sanitize_content_regression', + 'assumption-detection' => 'sanitize_assumption_detection', + 'pwa' => 'sanitize_pwa', + ); + $input_keys = array_keys( $value ); + $callback = isset( $sanitizers[ $slug ] ) ? array( '\Functionalities\Admin\Module_Controller', $sanitizers[ $slug ] ) : null; + if ( $callback && is_callable( $callback ) ) { + $clean = (array) call_user_func( $callback, $value ); + return array_intersect_key( $clean, array_flip( $input_keys ) ); + } + + if ( 'redirect-manager' === $slug ) { + $clean = array( + 'enabled' => ! empty( $value['enabled'] ), + 'monitor_404' => ! empty( $value['monitor_404'] ), + 'monitor_cap' => max( 25, min( 2000, (int) ( $value['monitor_cap'] ?? 500 ) ) ), + 'monitor_retention_days' => max( 1, min( 365, (int) ( $value['monitor_retention_days'] ?? 30 ) ) ), + 'monitor_exclusions' => \sanitize_textarea_field( $value['monitor_exclusions'] ?? '' ), + 'monitor_ignored_paths' => array_values( array_filter( array_map( 'sanitize_text_field', (array) ( $value['monitor_ignored_paths'] ?? array() ) ) ) ), + ); + return array_intersect_key( $clean, array_flip( $input_keys ) ); + } + + if ( 'svg-icons' === $slug ) { + $clean = array( 'enabled' => ! empty( $value['enabled'] ) ); + if ( $include_code && isset( $value['icons'] ) && is_array( $value['icons'] ) ) { + $clean['icons'] = array(); + foreach ( $value['icons'] as $icon_slug => $icon ) { + $icon_slug = \sanitize_key( $icon_slug ); + if ( $icon_slug && is_array( $icon ) && isset( $icon['svg'] ) ) { + $clean['icons'][ $icon_slug ] = array( + 'name' => \sanitize_text_field( $icon['name'] ?? $icon_slug ), + 'svg' => \Functionalities\Features\SVG_Icons::sanitize_svg( (string) $icon['svg'] ), + ); + } + } + } + return array_intersect_key( $clean, array_flip( $input_keys ) ); + } + + if ( 'task-manager' === $slug ) { + return array_intersect_key( array( 'enabled' => ! empty( $value['enabled'] ) ), array_flip( $input_keys ) ); + } + + return (array) self::sanitize_value( $value, $include_code ); + } + + /** + * Recursively normalize imported scalar values. + * + * @param mixed $value Imported value. + * @param bool $preserve_code Preserve explicitly opted-in code strings. + * @return mixed + */ + private static function sanitize_value( $value, bool $preserve_code = false ) { + if ( is_array( $value ) ) { + $clean = array(); + foreach ( $value as $key => $item ) { + $clean[ is_int( $key ) ? $key : \sanitize_key( $key ) ] = self::sanitize_value( $item, $preserve_code ); + } + return $clean; + } + if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) { + return $value; + } + return $preserve_code ? (string) $value : \sanitize_textarea_field( (string) $value ); + } +} diff --git a/includes/admin/class-site-health-controller.php b/includes/admin/class-site-health-controller.php new file mode 100644 index 0000000..f4067dd --- /dev/null +++ b/includes/admin/class-site-health-controller.php @@ -0,0 +1,262 @@ + \__( 'Functionalities assumption scan', 'functionalities' ), + 'test' => array( __CLASS__, 'get_site_health_result' ), + ); + return $tests; + } + + /** + * Build a current Site Health result. + * + * @return array + */ + public static function get_site_health_result(): array { + $options = (array) \get_option( + 'functionalities_assumption_detection', + array( + 'enabled' => false, + 'scan_schedule' => 'daily', + ) + ); + $last_run = (int) \get_option( 'functionalities_assumptions_last_run', 0 ); + $findings = (array) \get_option( \Functionalities\Features\Assumption_Detection::OPTION_KEY, array() ); + $count = count( $findings ); + $result = array( + 'label' => \__( 'Assumption Detection is current', 'functionalities' ), + 'status' => 'good', + 'badge' => array( + 'label' => \__( 'Functionalities', 'functionalities' ), + 'color' => 'blue', + ), + 'description' => '

    ' . \esc_html__( 'No active technical assumption changes were detected.', 'functionalities' ) . '

    ', + 'actions' => '', + 'test' => 'functionalities_assumptions', + ); + + if ( empty( $options['enabled'] ) ) { + $result['status'] = 'recommended'; + $result['label'] = \__( 'Assumption Detection is disabled', 'functionalities' ); + $result['description'] = '

    ' . \esc_html__( 'Enable the module to monitor schema, analytics, security, and runtime assumptions.', 'functionalities' ) . '

    '; + return $result; + } + if ( ! \wp_next_scheduled( self::CRON_HOOK ) ) { + $result['status'] = 'critical'; + $result['label'] = \__( 'The assumption scan is not scheduled', 'functionalities' ); + $result['description'] = '

    ' . \esc_html__( 'WordPress does not have a future background scan event. Save the module settings to reschedule it, then check WP-Cron if it disappears again.', 'functionalities' ) . '

    '; + return $result; + } + + $interval = self::get_interval( $options['scan_schedule'] ?? 'daily' ); + if ( ! $last_run || time() - $last_run > ( 2 * $interval ) ) { + $result['status'] = 'critical'; + $result['label'] = \__( 'The assumption scan is stale', 'functionalities' ); + $result['description'] = '

    ' . \esc_html__( 'The scheduled scan has not completed within two expected intervals. This is different from a clean scan.', 'functionalities' ) . '

    '; + return $result; + } + + if ( $count > 0 ) { + $critical_types = array( 'debug_exposure', 'mixed_content', 'missing_security_headers' ); + $has_critical = (bool) array_filter( + $findings, + static function ( array $finding ) use ( $critical_types ) { + return 'critical' === ( $finding['severity'] ?? '' ) || in_array( $finding['type'] ?? '', $critical_types, true ); + } + ); + $result['status'] = $has_critical ? 'critical' : 'recommended'; + $result['label'] = sprintf( + /* translators: %d: number of findings. */ + _n( '%d assumption change needs review', '%d assumption changes need review', $count, 'functionalities' ), + $count + ); + $result['description'] = '

    ' . \esc_html__( 'Review the findings in Functionalities. You can acknowledge, snooze, or ignore each finding.', 'functionalities' ) . '

    '; + } else { + $result['description'] = '

    ' . sprintf( + /* translators: %s: human-readable time difference. */ + \esc_html__( 'The last successful scan completed %s ago and found no active changes.', 'functionalities' ), + \esc_html( \human_time_diff( $last_run, time() ) ) + ) . '

    '; + } + + return $result; + } + + /** + * Register scan schedule and notification controls in the existing module. + * + * @return void + */ + public static function register_settings_fields(): void { + \add_settings_field( + 'assumption_scan_schedule', + \__( 'Background scans', 'functionalities' ), + array( __CLASS__, 'render_schedule_field' ), + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + \add_settings_field( + 'assumption_email_notifications', + \__( 'Email summaries', 'functionalities' ), + array( __CLASS__, 'render_notification_field' ), + 'functionalities_assumption_detection', + 'functionalities_assumption_detection_section' + ); + } + + /** + * Render scan frequency control. + * + * @return void + */ + public static function render_schedule_field(): void { + $options = (array) \get_option( 'functionalities_assumption_detection', array() ); + $value = $options['scan_schedule'] ?? 'daily'; + echo ''; + } + + /** + * Render opt-in email control. + * + * @return void + */ + public static function render_notification_field(): void { + $options = (array) \get_option( 'functionalities_assumption_detection', array() ); + echo ''; + } + + /** + * Ensure the selected recurring event is scheduled exactly once. + * + * @return void + */ + public static function reconcile_schedule(): void { + $options = (array) \get_option( + 'functionalities_assumption_detection', + array( + 'enabled' => false, + 'scan_schedule' => 'daily', + ) + ); + $recurrence = $options['scan_schedule'] ?? 'daily'; + $event = \wp_get_scheduled_event( self::CRON_HOOK ); + + if ( empty( $options['enabled'] ) ) { + if ( $event ) { + \wp_clear_scheduled_hook( self::CRON_HOOK ); + } + return; + } + + if ( ! $event || $event->schedule !== $recurrence ) { + \wp_clear_scheduled_hook( self::CRON_HOOK ); + \wp_schedule_event( time() + 300, $recurrence, self::CRON_HOOK ); + } + } + + /** + * Run a scan and send a deduplicated, rate-limited summary when enabled. + * + * @return void + */ + public static function run_background_scan(): void { + \Functionalities\Core\Module_Registry::boot_module( 'assumption-detection' ); + $findings = \Functionalities\Features\Assumption_Detection::force_run_detection(); + $signature = array_map( + static function ( array $finding ) { + unset( $finding['detected'], $finding['timestamp'] ); + return $finding; + }, + $findings + ); + usort( + $signature, + static function ( array $a, array $b ) { + return strcmp( \wp_json_encode( $a ), \wp_json_encode( $b ) ); + } + ); + $digest = md5( \wp_json_encode( $signature ) ); + $state = (array) \get_option( 'functionalities_assumption_scan_summary', array() ); + $options = (array) \get_option( 'functionalities_assumption_detection', array() ); + $now = time(); + + if ( ! empty( $options['email_notifications'] ) && $digest !== ( $state['digest'] ?? '' ) && $now - (int) ( $state['notified_at'] ?? 0 ) >= DAY_IN_SECONDS ) { + $subject = sprintf( + /* translators: %d: number of findings. */ + \__( '[Functionalities] Assumption scan found %d item(s)', 'functionalities' ), + count( $findings ) + ); + $message = \__( 'The finding set changed. Review it in WordPress under Functionalities > Assumption Detection. No content, URLs, or diagnostics are included in this email.', 'functionalities' ); + if ( \wp_mail( \get_option( 'admin_email' ), $subject, $message ) ) { + $state['notified_at'] = $now; + } + } + $state['digest'] = $digest; + $state['count'] = count( $findings ); + $state['scanned'] = $now; + \update_option( 'functionalities_assumption_scan_summary', $state, false ); + } + + /** + * Return the expected number of seconds between scans. + * + * @param string $schedule Recurrence key. + * @return int + */ + private static function get_interval( string $schedule ): int { + $interval = array( + 'hourly' => HOUR_IN_SECONDS, + 'twicedaily' => 12 * HOUR_IN_SECONDS, + 'daily' => DAY_IN_SECONDS, + 'weekly' => WEEK_IN_SECONDS, + ); + return $interval[ $schedule ] ?? DAY_IN_SECONDS; + } +} diff --git a/includes/admin/class-svg-icons-controller.php b/includes/admin/class-svg-icons-controller.php new file mode 100644 index 0000000..319f321 --- /dev/null +++ b/includes/admin/class-svg-icons-controller.php @@ -0,0 +1,27 @@ + false, 'nofollow_external' => false, @@ -33,7 +33,7 @@ public static function get_link_management_options() : array { 'json_preset_url' => '', 'enable_developer_filters' => false, ); - $opts = (array) \get_option( 'functionalities_link_management', $defaults ); + $opts = (array) \get_option( 'functionalities_link_management', $defaults ); return array_merge( $defaults, $opts ); } @@ -42,7 +42,7 @@ public static function get_link_management_options() : array { * * @return array Block cleanup options. */ - public static function get_block_cleanup_options() : array { + public static function get_block_cleanup_options(): array { $defaults = array( 'enabled' => false, 'remove_heading_block_class' => false, @@ -59,7 +59,7 @@ public static function get_block_cleanup_options() : array { 'remove_media_text_block_class' => false, 'custom_classes_to_remove' => '', ); - $opts = (array) \get_option( 'functionalities_block_cleanup', $defaults ); + $opts = (array) \get_option( 'functionalities_block_cleanup', $defaults ); return array_merge( $defaults, $opts ); } @@ -68,7 +68,7 @@ public static function get_block_cleanup_options() : array { * * @return array Default post types. */ - public static function default_editor_link_post_types() : array { + public static function default_editor_link_post_types(): array { $pts = get_post_types( array( 'public' => true ), 'objects' ); $defaults = array(); foreach ( $pts as $name => $obj ) { @@ -85,13 +85,13 @@ public static function default_editor_link_post_types() : array { * * @return array Editor links options. */ - public static function get_editor_links_options() : array { + public static function get_editor_links_options(): array { $defaults = array( 'enabled' => false, 'enable_limit' => false, 'post_types' => self::default_editor_link_post_types(), ); - $opts = (array) \get_option( 'functionalities_editor_links', $defaults ); + $opts = (array) \get_option( 'functionalities_editor_links', $defaults ); return array_merge( $defaults, $opts ); } @@ -105,7 +105,7 @@ public static function get_editor_links_options() : array { * * @return array Snippets options. */ - public static function get_snippets_options() : array { + public static function get_snippets_options(): array { $defaults = array( 'enabled' => false, 'enable_ga4' => false, @@ -114,7 +114,7 @@ public static function get_snippets_options() : array { 'body_open' => array(), 'footer' => array(), ); - $opts = (array) \get_option( 'functionalities_snippets', $defaults ); + $opts = (array) \get_option( 'functionalities_snippets', $defaults ); // Migrate old single-string format to repeater arrays. if ( isset( $opts['header_code'] ) || isset( $opts['enable_header'] ) ) { @@ -130,7 +130,7 @@ public static function get_snippets_options() : array { * * @return array Schema options. */ - public static function get_schema_options() : array { + public static function get_schema_options(): array { $defaults = array( 'enabled' => false, 'enable_site_schema' => true, @@ -144,7 +144,7 @@ public static function get_schema_options() : array { 'add_author' => true, 'enable_breadcrumbs' => false, ); - $opts = (array) \get_option( 'functionalities_schema', $defaults ); + $opts = (array) \get_option( 'functionalities_schema', $defaults ); return array_merge( $defaults, $opts ); } @@ -153,7 +153,7 @@ public static function get_schema_options() : array { * * @return array Default components. */ - public static function default_components() : array { + public static function default_components(): array { return array( array( 'name' => 'Card', @@ -228,12 +228,12 @@ public static function default_components() : array { * * @return array Components options. */ - public static function get_components_options() : array { + public static function get_components_options(): array { $defaults = array( 'enabled' => false, 'items' => self::default_components(), ); - $opts = (array) \get_option( 'functionalities_components', $defaults ); + $opts = (array) \get_option( 'functionalities_components', $defaults ); if ( empty( $opts['items'] ) ) { $opts['items'] = self::default_components(); } @@ -245,7 +245,7 @@ public static function get_components_options() : array { * * @return array Misc options. */ - public static function get_misc_options() : array { + public static function get_misc_options(): array { $defaults = array( 'enabled' => false, 'disable_block_widgets' => false, @@ -271,7 +271,7 @@ public static function get_misc_options() : array { 'enable_prism_admin' => false, 'enable_textarea_fullscreen' => false, ); - $opts = (array) \get_option( 'functionalities_misc', $defaults ); + $opts = (array) \get_option( 'functionalities_misc', $defaults ); return array_merge( $defaults, $opts ); } @@ -280,12 +280,12 @@ public static function get_misc_options() : array { * * @return array Fonts options. */ - public static function get_fonts_options() : array { + public static function get_fonts_options(): array { $defaults = array( 'enabled' => false, 'items' => array(), ); - $opts = (array) \get_option( 'functionalities_fonts', $defaults ); + $opts = (array) \get_option( 'functionalities_fonts', $defaults ); return array_merge( $defaults, $opts ); } @@ -294,7 +294,7 @@ public static function get_fonts_options() : array { * * @return array Options. */ - public static function get_login_security_options() : array { + public static function get_login_security_options(): array { return \Functionalities\Features\Login_Security::get_options(); } @@ -303,21 +303,21 @@ public static function get_login_security_options() : array { * * @return array Meta options. */ - public static function get_meta_options() : array { + public static function get_meta_options(): array { $defaults = array( - 'enabled' => false, - 'enable_copyright_meta' => true, - 'enable_dublin_core' => true, - 'enable_license_metabox' => true, + 'enabled' => false, + 'enable_copyright_meta' => true, + 'enable_dublin_core' => true, + 'enable_license_metabox' => true, 'enable_schema_integration' => true, - 'default_license' => 'all-rights-reserved', - 'default_license_url' => '', - 'post_types' => array( 'post' ), - 'copyright_holder_type' => 'author', - 'custom_copyright_holder' => '', - 'dc_language' => '', + 'default_license' => 'all-rights-reserved', + 'default_license_url' => '', + 'post_types' => array( 'post' ), + 'copyright_holder_type' => 'author', + 'custom_copyright_holder' => '', + 'dc_language' => '', ); - $opts = (array) \get_option( 'functionalities_meta', $defaults ); + $opts = (array) \get_option( 'functionalities_meta', $defaults ); return array_merge( $defaults, $opts ); } @@ -326,7 +326,7 @@ public static function get_meta_options() : array { * * @return array Content regression options. */ - public static function get_content_regression_options() : array { + public static function get_content_regression_options(): array { $defaults = array( 'enabled' => false, 'post_types' => array( 'post', 'page' ), @@ -346,7 +346,7 @@ public static function get_content_regression_options() : array { 'snapshot_rolling_count' => 5, 'show_post_column' => true, ); - $opts = (array) \get_option( 'functionalities_content_regression', $defaults ); + $opts = (array) \get_option( 'functionalities_content_regression', $defaults ); return array_merge( $defaults, $opts ); } @@ -355,7 +355,7 @@ public static function get_content_regression_options() : array { * * @return array Assumption detection options. */ - public static function get_assumption_detection_options() : array { + public static function get_assumption_detection_options(): array { $defaults = array( 'enabled' => false, 'detect_schema_collision' => true, @@ -371,8 +371,10 @@ public static function get_assumption_detection_options() : array { 'detect_missing_security_headers' => true, 'detect_debug_exposure' => true, 'detect_cron_issues' => true, + 'scan_schedule' => 'daily', + 'email_notifications' => false, ); - $opts = (array) \get_option( 'functionalities_assumption_detection', $defaults ); + $opts = (array) \get_option( 'functionalities_assumption_detection', $defaults ); return array_merge( $defaults, $opts ); } @@ -381,7 +383,7 @@ public static function get_assumption_detection_options() : array { * * @return array Options. */ - public static function get_svg_icons_options() : array { + public static function get_svg_icons_options(): array { return \Functionalities\Features\SVG_Icons::get_options(); } @@ -390,7 +392,7 @@ public static function get_svg_icons_options() : array { * * @return array PWA options. */ - public static function get_pwa_options() : array { + public static function get_pwa_options(): array { return \Functionalities\Features\PWA::get_options(); } } diff --git a/includes/admin/trait-admin-sanitizers.php b/includes/admin/trait-admin-sanitizers.php index 4454df4..90ae07e 100644 --- a/includes/admin/trait-admin-sanitizers.php +++ b/includes/admin/trait-admin-sanitizers.php @@ -23,7 +23,7 @@ trait Admin_Sanitizers { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_link_management( $input ) : array { + public static function sanitize_link_management( $input ): array { $out = array( 'enabled' => ! empty( $input['enabled'] ), 'nofollow_external' => ! empty( $input['nofollow_external'] ), @@ -77,7 +77,7 @@ public static function sanitize_link_management( $input ) : array { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_block_cleanup( $input ) : array { + public static function sanitize_block_cleanup( $input ): array { return array( 'enabled' => ! empty( $input['enabled'] ), 'remove_heading_block_class' => ! empty( $input['remove_heading_block_class'] ), @@ -104,7 +104,7 @@ public static function sanitize_block_cleanup( $input ) : array { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_editor_links( $input ) : array { + public static function sanitize_editor_links( $input ): array { $out = array( 'enabled' => ! empty( $input['enabled'] ), 'enable_limit' => ! empty( $input['enable_limit'] ), @@ -136,7 +136,7 @@ public static function sanitize_editor_links( $input ) : array { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_snippets( $input ) : array { + public static function sanitize_snippets( $input ): array { $out = array( 'enabled' => ! empty( $input['enabled'] ), 'enable_ga4' => ! empty( $input['enable_ga4'] ), @@ -185,7 +185,7 @@ public static function sanitize_snippets( $input ) : array { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_schema( $input ) : array { + public static function sanitize_schema( $input ): array { return array( 'enabled' => ! empty( $input['enabled'] ), 'enable_site_schema' => ! empty( $input['enable_site_schema'] ), @@ -207,7 +207,7 @@ public static function sanitize_schema( $input ) : array { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_components( $input ) : array { + public static function sanitize_components( $input ): array { $out = array( 'enabled' => ! empty( $input['enabled'] ), 'items' => array(), @@ -236,7 +236,7 @@ public static function sanitize_components( $input ) : array { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_misc( $input ) : array { + public static function sanitize_misc( $input ): array { $keys = array( 'enabled', 'disable_block_widgets', @@ -262,7 +262,7 @@ public static function sanitize_misc( $input ) : array { 'enable_prism_admin', 'enable_textarea_fullscreen', ); - $out = array(); + $out = array(); foreach ( $keys as $k ) { $out[ $k ] = ! empty( $input[ $k ] ); } @@ -279,7 +279,7 @@ public static function sanitize_misc( $input ) : array { * @param string $style The font-style value to sanitize. * @return string Sanitized font-style value, defaults to 'normal' if invalid. */ - protected static function sanitize_font_style( string $style ) : string { + protected static function sanitize_font_style( string $style ): string { $style = trim( strtolower( $style ) ); // Simple keyword values. @@ -313,7 +313,7 @@ protected static function sanitize_font_style( string $style ) : string { * @param string $value Raw unicode-range value. * @return string Sanitized value, or empty string if nothing valid remains. */ - protected static function sanitize_unicode_range( string $value ) : string { + protected static function sanitize_unicode_range( string $value ): string { $value = trim( $value ); if ( $value === '' ) { return ''; @@ -349,7 +349,7 @@ protected static function sanitize_unicode_range( string $value ) : string { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_fonts( $input ) : array { + public static function sanitize_fonts( $input ): array { $out = array( 'enabled' => ! empty( $input['enabled'] ), 'items' => array(), @@ -414,7 +414,7 @@ public static function sanitize_fonts( $input ) : array { * @param array $input Raw input. * @return array Sanitized output. */ - public static function sanitize_login_security( $input ) : array { + public static function sanitize_login_security( $input ): array { return array( 'enabled' => ! empty( $input['enabled'] ), 'limit_login_attempts' => ! empty( $input['limit_login_attempts'] ), @@ -436,19 +436,19 @@ public static function sanitize_login_security( $input ) : array { * @param array $input Raw input. * @return array Sanitized output. */ - public static function sanitize_meta( $input ) : array { + public static function sanitize_meta( $input ): array { $out = array( - 'enabled' => ! empty( $input['enabled'] ), - 'enable_copyright_meta' => ! empty( $input['enable_copyright_meta'] ), - 'enable_dublin_core' => ! empty( $input['enable_dublin_core'] ), - 'enable_license_metabox' => ! empty( $input['enable_license_metabox'] ), + 'enabled' => ! empty( $input['enabled'] ), + 'enable_copyright_meta' => ! empty( $input['enable_copyright_meta'] ), + 'enable_dublin_core' => ! empty( $input['enable_dublin_core'] ), + 'enable_license_metabox' => ! empty( $input['enable_license_metabox'] ), 'enable_schema_integration' => ! empty( $input['enable_schema_integration'] ), - 'default_license' => \sanitize_key( $input['default_license'] ?? 'all-rights-reserved' ), - 'default_license_url' => \esc_url_raw( $input['default_license_url'] ?? '' ), - 'post_types' => array(), - 'copyright_holder_type' => \sanitize_key( $input['copyright_holder_type'] ?? 'author' ), - 'custom_copyright_holder' => \sanitize_text_field( $input['custom_copyright_holder'] ?? '' ), - 'dc_language' => \sanitize_text_field( $input['dc_language'] ?? '' ), + 'default_license' => \sanitize_key( $input['default_license'] ?? 'all-rights-reserved' ), + 'default_license_url' => \esc_url_raw( $input['default_license_url'] ?? '' ), + 'post_types' => array(), + 'copyright_holder_type' => \sanitize_key( $input['copyright_holder_type'] ?? 'author' ), + 'custom_copyright_holder' => \sanitize_text_field( $input['custom_copyright_holder'] ?? '' ), + 'dc_language' => \sanitize_text_field( $input['dc_language'] ?? '' ), ); if ( isset( $input['post_types'] ) && is_array( $input['post_types'] ) ) { @@ -472,7 +472,7 @@ public static function sanitize_meta( $input ) : array { * @param array $input Raw input. * @return array Sanitized output. */ - public static function sanitize_content_regression( $input ) : array { + public static function sanitize_content_regression( $input ): array { $out = array( 'enabled' => ! empty( $input['enabled'] ), 'post_types' => array(), @@ -514,7 +514,8 @@ public static function sanitize_content_regression( $input ) : array { * @param array $input Raw input. * @return array Sanitized output. */ - public static function sanitize_assumption_detection( $input ) : array { + public static function sanitize_assumption_detection( $input ): array { + $schedule = isset( $input['scan_schedule'] ) ? \sanitize_key( $input['scan_schedule'] ) : 'daily'; return array( 'enabled' => ! empty( $input['enabled'] ), 'detect_schema_collision' => ! empty( $input['detect_schema_collision'] ), @@ -530,6 +531,8 @@ public static function sanitize_assumption_detection( $input ) : array { 'detect_missing_security_headers' => ! empty( $input['detect_missing_security_headers'] ), 'detect_debug_exposure' => ! empty( $input['detect_debug_exposure'] ), 'detect_cron_issues' => ! empty( $input['detect_cron_issues'] ), + 'scan_schedule' => in_array( $schedule, array( 'hourly', 'twicedaily', 'daily', 'weekly' ), true ) ? $schedule : 'daily', + 'email_notifications' => ! empty( $input['email_notifications'] ), ); } @@ -539,7 +542,7 @@ public static function sanitize_assumption_detection( $input ) : array { * @param array $input Raw input data. * @return array Sanitized data. */ - public static function sanitize_pwa( $input ) : array { + public static function sanitize_pwa( $input ): array { $allowed_displays = array( 'standalone', 'fullscreen', 'minimal-ui', 'browser' ); $allowed_orientations = array( 'any', 'portrait', 'landscape', 'portrait-primary', 'landscape-primary' ); $allowed_positions = array( 'bottom', 'top', 'center' ); @@ -639,7 +642,7 @@ public static function sanitize_pwa( $input ) : array { if ( '' === $src ) { continue; } - $form_factor = \sanitize_text_field( $ss['form_factor'] ?? 'wide' ); + $form_factor = \sanitize_text_field( $ss['form_factor'] ?? 'wide' ); $screenshots[] = array( 'src' => $src, 'sizes' => \sanitize_text_field( $ss['sizes'] ?? '' ), diff --git a/includes/core/class-module-registry.php b/includes/core/class-module-registry.php new file mode 100644 index 0000000..2ad4e70 --- /dev/null +++ b/includes/core/class-module-registry.php @@ -0,0 +1,210 @@ + self::definition( __( 'Task Manager', 'functionalities' ), __( 'File-based project task management with JSON storage.', 'functionalities' ), 'dashicons-yes-alt', 'Task_Manager', true ), + 'misc' => self::definition( __( 'Performance & Cleanup', 'functionalities' ), __( 'Disable bloat, emojis, embeds, heartbeat, and more.', 'functionalities' ), 'dashicons-performance', 'Misc' ), + 'snippets' => self::definition( __( 'Header & Footer', 'functionalities' ), __( 'Add GA4, custom header and footer code.', 'functionalities' ), 'dashicons-editor-code', 'Snippets' ), + 'link-management' => self::definition( __( 'Link Management', 'functionalities' ), __( 'Control nofollow, new tabs, and link behavior.', 'functionalities' ), 'dashicons-admin-links', 'Link_Management' ), + 'redirect-manager' => self::definition( __( 'Redirect Manager', 'functionalities' ), __( 'Create and manage 301/302 URL redirects.', 'functionalities' ), 'dashicons-randomize', 'Redirect_Manager', true ), + 'block-cleanup' => self::definition( __( 'Block Cleanup', 'functionalities' ), __( 'Strip block classes from frontend output.', 'functionalities' ), 'dashicons-block-default', 'Block_Cleanup' ), + 'schema' => self::definition( __( 'Schema Settings', 'functionalities' ), __( 'Add microdata to key areas and content.', 'functionalities' ), 'dashicons-networking', 'Schema' ), + 'content-regression' => self::definition( __( 'Content Integrity', 'functionalities' ), __( 'Detect structural regressions when posts are updated.', 'functionalities' ), 'dashicons-shield', 'Content_Regression' ), + 'assumption-detection' => self::definition( __( 'Assumption Detection', 'functionalities' ), __( 'Notice when implicit site assumptions stop being true.', 'functionalities' ), 'dashicons-visibility', 'Assumption_Detection' ), + 'login-security' => self::definition( __( 'Login Security', 'functionalities' ), __( 'Limit login attempts, customize login page, block XML-RPC.', 'functionalities' ), 'dashicons-lock', 'Login_Security' ), + 'meta' => self::definition( __( 'Meta & Copyright', 'functionalities' ), __( 'Copyright, Dublin Core, licensing, and SEO plugin integration.', 'functionalities' ), 'dashicons-media-text', 'Meta' ), + 'components' => self::definition( __( 'Components', 'functionalities' ), __( 'Define reusable CSS components.', 'functionalities' ), 'dashicons-layout', 'Components' ), + 'fonts' => self::definition( __( 'Fonts', 'functionalities' ), __( 'Register custom font families.', 'functionalities' ), 'dashicons-editor-textcolor', 'Fonts' ), + 'editor-links' => self::definition( __( 'Editor Link Suggestions', 'functionalities' ), __( 'Limit link suggestions to selected post types.', 'functionalities' ), 'dashicons-editor-unlink', 'Editor_Links' ), + 'svg-icons' => self::definition( __( 'SVG Icons', 'functionalities' ), __( 'Upload custom SVG icons and insert them inline in the block editor.', 'functionalities' ), 'dashicons-flag', 'SVG_Icons', true ), + 'pwa' => self::definition( __( 'Progressive Web App', 'functionalities' ), __( 'Make your site installable and work offline.', 'functionalities' ), 'dashicons-smartphone', 'PWA' ), + ); + } + + /** + * Return module metadata for the admin dashboard. + * + * @return array + */ + public static function get_admin_modules(): array { + $modules = array(); + foreach ( self::get_definitions() as $slug => $definition ) { + $modules[ $slug ] = array( + 'title' => $definition['title'], + 'description' => $definition['description'], + 'icon' => $definition['icon'], + 'custom_page' => $definition['custom_page'], + 'controller' => $definition['controller'], + ); + } + return $modules; + } + + /** + * Load every module required for the current request. + * + * @return void + */ + public static function boot(): void { + foreach ( self::get_definitions() as $slug => $definition ) { + if ( self::is_enabled( $slug ) || self::is_admin_context( $slug ) ) { + self::boot_module( $slug ); + } + } + } + + /** + * Load and initialize one module. + * + * @param string $slug Module slug. + * @return bool + */ + public static function boot_module( string $slug ): bool { + $definitions = self::get_definitions(); + if ( isset( self::$booted[ $slug ] ) || ! isset( $definitions[ $slug ] ) ) { + return isset( self::$booted[ $slug ] ); + } + + $class = $definitions[ $slug ]['class']; + if ( ! class_exists( $class ) || ! is_callable( array( $class, 'init' ) ) ) { + return false; + } + + self::$booted[ $slug ] = true; + call_user_func( array( $class, 'init' ) ); + return true; + } + + /** + * Check a module's stored and filtered enabled state without loading it. + * + * @param string $slug Module slug. + * @return bool + */ + public static function is_enabled( string $slug ): bool { + $definitions = self::get_definitions(); + if ( ! isset( $definitions[ $slug ] ) ) { + return false; + } + + $options = (array) get_option( $definitions[ $slug ]['option'], array( 'enabled' => $definitions[ $slug ]['default_enabled'] ) ); + $enabled = ! empty( $options['enabled'] ); + $enabled = (bool) apply_filters( 'functionalities_' . str_replace( '-', '_', $slug ) . '_enabled', $enabled ); + + return (bool) apply_filters( 'functionalities_module_enabled', $enabled, $slug, $options ); + } + + /** + * Return a module option name. + * + * @param string $slug Module slug. + * @return string + */ + public static function get_option_name( string $slug ): string { + $definitions = self::get_definitions(); + return isset( $definitions[ $slug ] ) ? $definitions[ $slug ]['option'] : ''; + } + + /** + * React to settings updates that require feature-specific maintenance. + * + * @param string $option Option name. + * @param mixed $old_value Previous value. + * @param mixed $value New value. + * @return void + */ + public static function handle_option_update( string $option, $old_value, $value ): void { + if ( 'functionalities_pwa' !== $option ) { + return; + } + + self::boot_module( 'pwa' ); + if ( is_callable( array( '\Functionalities\Features\PWA', 'on_option_update' ) ) ) { + \Functionalities\Features\PWA::on_option_update( $old_value, $value ); + } + } + + /** + * Build one normalized definition. + * + * @param string $title English title. + * @param string $description English description. + * @param string $icon Dashicon class. + * @param string $class_name Feature class basename. + * @param bool $custom_page Whether the module has a custom admin page. + * @return array + */ + private static function definition( string $title, string $description, string $icon, string $class_name, bool $custom_page = false ): array { + $slug = strtolower( str_replace( '_', '-', $class_name ) ); + return array( + 'title' => $title, + 'description' => $description, + 'icon' => $icon, + 'class' => '\\Functionalities\\Features\\' . $class_name, + 'option' => 'functionalities_' . str_replace( '-', '_', $slug ), + 'default_enabled' => false, + 'custom_page' => $custom_page, + 'controller' => $custom_page ? '\\Functionalities\\Admin\\' . $class_name . '_Controller' : '', + ); + } + + /** + * Check whether an otherwise-disabled module is needed in wp-admin. + * + * @param string $slug Module slug. + * @return bool + */ + private static function is_admin_context( string $slug ): bool { + if ( ! is_admin() ) { + return false; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing context. + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing context. + $module = isset( $_GET['module'] ) ? sanitize_key( wp_unslash( $_GET['module'] ) ) : ''; + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- admin-ajax action routing. + $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; + + if ( $slug === $module || 'functionalities-' . $slug === $page ) { + return true; + } + + $prefixes = array( + 'task-manager' => 'functionalities_task_', + 'redirect-manager' => 'functionalities_redirect_', + 'svg-icons' => 'functionalities_svg_icon_', + 'assumption-detection' => 'functionalities_run_detection', + ); + + return isset( $prefixes[ $slug ] ) && 0 === strpos( $action, $prefixes[ $slug ] ); + } +} diff --git a/includes/features/class-assumption-detection.php b/includes/features/class-assumption-detection.php index 7521558..b0eb05d 100644 --- a/includes/features/class-assumption-detection.php +++ b/includes/features/class-assumption-detection.php @@ -64,7 +64,7 @@ class Assumption_Detection { * * @return void */ - public static function init() : void { + public static function init(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -99,28 +99,30 @@ public static function init() : void { * * @return array Options array. */ - public static function get_options() : array { + public static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( - 'enabled' => false, - 'detect_schema_collision' => true, - 'detect_analytics_dupe' => true, - 'detect_font_redundancy' => true, - 'detect_inline_css_growth' => true, - 'inline_css_threshold_kb' => 50, - 'detect_jquery_conflicts' => true, - 'detect_meta_duplication' => true, - 'detect_rest_exposure' => true, - 'detect_lazy_load_conflict' => true, - 'detect_mixed_content' => true, + $defaults = array( + 'enabled' => false, + 'detect_schema_collision' => true, + 'detect_analytics_dupe' => true, + 'detect_font_redundancy' => true, + 'detect_inline_css_growth' => true, + 'inline_css_threshold_kb' => 50, + 'detect_jquery_conflicts' => true, + 'detect_meta_duplication' => true, + 'detect_rest_exposure' => true, + 'detect_lazy_load_conflict' => true, + 'detect_mixed_content' => true, 'detect_missing_security_headers' => true, - 'detect_debug_exposure' => true, - 'detect_cron_issues' => true, + 'detect_debug_exposure' => true, + 'detect_cron_issues' => true, + 'scan_schedule' => 'daily', + 'email_notifications' => false, ); - $opts = (array) \get_option( 'functionalities_assumption_detection', $defaults ); + $opts = (array) \get_option( 'functionalities_assumption_detection', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -130,7 +132,7 @@ public static function get_options() : array { * * @return void */ - public static function schedule_detection() : void { + public static function schedule_detection(): void { \set_transient( 'functionalities_run_assumption_detection', true, HOUR_IN_SECONDS ); } @@ -139,14 +141,14 @@ public static function schedule_detection() : void { * * @return void */ - public static function run_detection() : void { - $opts = self::get_options(); + public static function run_detection(): void { + $opts = self::get_options(); $warnings = array(); - $ignored = self::get_ignored_assumptions(); + $ignored = self::get_ignored_assumptions(); // Check if we need to run detection. - $should_run = \get_transient( 'functionalities_run_assumption_detection' ); - $last_run = \get_option( 'functionalities_assumptions_last_run', 0 ); + $should_run = \get_transient( 'functionalities_run_assumption_detection' ); + $last_run = \get_option( 'functionalities_assumptions_last_run', 0 ); $cache_duration = 6 * HOUR_IN_SECONDS; // Only run if scheduled or cache expired. @@ -160,69 +162,72 @@ public static function run_detection() : void { // Run detectors. if ( ! empty( $opts['detect_schema_collision'] ) ) { $schema_warnings = self::detect_schema_collisions(); - $warnings = array_merge( $warnings, $schema_warnings ); + $warnings = array_merge( $warnings, $schema_warnings ); } if ( ! empty( $opts['detect_analytics_dupe'] ) ) { $analytics_warnings = self::detect_analytics_duplication(); - $warnings = array_merge( $warnings, $analytics_warnings ); + $warnings = array_merge( $warnings, $analytics_warnings ); } if ( ! empty( $opts['detect_font_redundancy'] ) ) { $font_warnings = self::detect_font_redundancy(); - $warnings = array_merge( $warnings, $font_warnings ); + $warnings = array_merge( $warnings, $font_warnings ); } if ( ! empty( $opts['detect_inline_css_growth'] ) ) { $css_warnings = self::detect_inline_css_growth( $opts ); - $warnings = array_merge( $warnings, $css_warnings ); + $warnings = array_merge( $warnings, $css_warnings ); } if ( ! empty( $opts['detect_jquery_conflicts'] ) ) { $jquery_warnings = self::detect_jquery_conflicts(); - $warnings = array_merge( $warnings, $jquery_warnings ); + $warnings = array_merge( $warnings, $jquery_warnings ); } if ( ! empty( $opts['detect_meta_duplication'] ) ) { $meta_warnings = self::detect_meta_duplication(); - $warnings = array_merge( $warnings, $meta_warnings ); + $warnings = array_merge( $warnings, $meta_warnings ); } if ( ! empty( $opts['detect_rest_exposure'] ) ) { $rest_warnings = self::detect_rest_exposure(); - $warnings = array_merge( $warnings, $rest_warnings ); + $warnings = array_merge( $warnings, $rest_warnings ); } if ( ! empty( $opts['detect_lazy_load_conflict'] ) ) { $lazy_warnings = self::detect_lazy_load_conflicts(); - $warnings = array_merge( $warnings, $lazy_warnings ); + $warnings = array_merge( $warnings, $lazy_warnings ); } if ( ! empty( $opts['detect_mixed_content'] ) ) { $mixed_warnings = self::detect_mixed_content(); - $warnings = array_merge( $warnings, $mixed_warnings ); + $warnings = array_merge( $warnings, $mixed_warnings ); } if ( ! empty( $opts['detect_missing_security_headers'] ) ) { $header_warnings = self::detect_missing_security_headers(); - $warnings = array_merge( $warnings, $header_warnings ); + $warnings = array_merge( $warnings, $header_warnings ); } if ( ! empty( $opts['detect_debug_exposure'] ) ) { $debug_warnings = self::detect_debug_exposure(); - $warnings = array_merge( $warnings, $debug_warnings ); + $warnings = array_merge( $warnings, $debug_warnings ); } if ( ! empty( $opts['detect_cron_issues'] ) ) { $cron_warnings = self::detect_cron_issues(); - $warnings = array_merge( $warnings, $cron_warnings ); + $warnings = array_merge( $warnings, $cron_warnings ); } // Filter out ignored warnings. - $warnings = array_filter( $warnings, function( $warning ) use ( $ignored ) { - $hash = self::get_warning_hash( $warning ); - return ! isset( $ignored[ $hash ] ) || $ignored[ $hash ]['expires'] < time(); - } ); + $warnings = array_filter( + $warnings, + function ( $warning ) use ( $ignored ) { + $hash = self::get_warning_hash( $warning ); + return ! isset( $ignored[ $hash ] ) || $ignored[ $hash ]['expires'] < time(); + } + ); // Store results. \update_option( self::OPTION_KEY, $warnings ); @@ -234,11 +239,11 @@ public static function run_detection() : void { * * @return array Array of warnings. */ - public static function detect_schema_collisions() : array { + public static function detect_schema_collisions(): array { $warnings = array(); // Use cached frontend output. - $output = self::get_frontend_output(); + $output = self::get_frontend_output(); $full_output = $output['full']; // Find all JSON-LD scripts. @@ -272,7 +277,7 @@ public static function detect_schema_collisions() : array { } // Try to identify the source. - $source = self::identify_schema_source( $item ); + $source = self::identify_schema_source( $item ); $schema_types[ $type ][] = $source; } } @@ -284,25 +289,25 @@ public static function detect_schema_collisions() : array { $unique_sources = array_unique( $sources ); if ( count( $unique_sources ) > 1 ) { $warnings[] = array( - 'type' => 'schema_collision', - 'message' => sprintf( + 'type' => 'schema_collision', + 'message' => sprintf( /* translators: 1: schema type, 2: source list */ \__( 'Multiple sources are outputting %1$s schema (%2$s).', 'functionalities' ), $type, implode( ' + ', $unique_sources ) ), - 'location' => sprintf( + 'location' => sprintf( /* translators: %s: source list */ \__( 'Found in page <head> output from: %s', 'functionalities' ), implode( ', ', $unique_sources ) ), - 'reason' => \__( 'Multiple schema sources cause duplicate structured data in search results, potentially confusing search engines and diluting SEO benefits. Only one source should output each schema type.', 'functionalities' ), - 'details' => array( + 'reason' => \__( 'Multiple schema sources cause duplicate structured data in search results, potentially confusing search engines and diluting SEO benefits. Only one source should output each schema type.', 'functionalities' ), + 'details' => array( 'schema_type' => $type, 'sources' => $sources, 'count' => count( $sources ), ), - 'detected' => time(), + 'detected' => time(), ); } } @@ -317,9 +322,9 @@ public static function detect_schema_collisions() : array { * @param array $item Schema item. * @return string Source identifier. */ - protected static function identify_schema_source( array $item ) : string { + protected static function identify_schema_source( array $item ): string { // Check for known plugin patterns. - $json = json_encode( $item ); + $json = wp_json_encode( $item ); if ( strpos( $json, 'rank-math' ) !== false || strpos( $json, 'rankMath' ) !== false ) { return 'Rank Math'; @@ -357,11 +362,11 @@ protected static function identify_schema_source( array $item ) : string { * * @return array Array of warnings. */ - public static function detect_analytics_duplication() : array { + public static function detect_analytics_duplication(): array { $warnings = array(); // Use cached frontend output. - $output = self::get_frontend_output(); + $output = self::get_frontend_output(); $full_output = $output['full']; // Also check enqueued scripts. @@ -377,15 +382,15 @@ public static function detect_analytics_duplication() : array { // Patterns to detect. $analytics_patterns = array( - 'ga4' => array( + 'ga4' => array( 'pattern' => '/["\']?(G-[A-Z0-9]+)["\']?/i', 'name' => 'Google Analytics 4', ), - 'ua' => array( + 'ua' => array( 'pattern' => '/["\']?(UA-\d+-\d+)["\']?/i', 'name' => 'Universal Analytics', ), - 'gtm' => array( + 'gtm' => array( 'pattern' => '/["\']?(GTM-[A-Z0-9]+)["\']?/i', 'name' => 'Google Tag Manager', ), @@ -409,29 +414,29 @@ public static function detect_analytics_duplication() : array { $locations = self::find_script_locations( $full_output, $id ); $warnings[] = array( - 'type' => 'analytics_duplication', - 'message' => sprintf( + 'type' => 'analytics_duplication', + 'message' => sprintf( /* translators: 1: analytics name, 2: ID, 3: count */ \__( '%1$s (%2$s) is loaded %3$d times from different sources.', 'functionalities' ), $config['name'], $id, $count ), - 'location' => ! empty( $locations ) + 'location' => ! empty( $locations ) ? sprintf( /* translators: %s: location list */ \__( 'Detected in: %s', 'functionalities' ), implode( ', ', $locations ) ) : \__( 'Found in page HTML output', 'functionalities' ), - 'reason' => \__( 'Duplicate tracking codes cause inflated pageview counts, skewed session data, and can slow page load. Each analytics property should only be initialized once per page.', 'functionalities' ), - 'details' => array( + 'reason' => \__( 'Duplicate tracking codes cause inflated pageview counts, skewed session data, and can slow page load. Each analytics property should only be initialized once per page.', 'functionalities' ), + 'details' => array( 'analytics_type' => $config['name'], 'tracking_id' => $id, 'count' => $count, 'locations' => $locations, ), - 'detected' => time(), + 'detected' => time(), ); } } @@ -448,7 +453,7 @@ public static function detect_analytics_duplication() : array { * @param string $id Tracking ID. * @return array Location descriptions. */ - protected static function find_script_locations( string $output, string $id ) : array { + protected static function find_script_locations( string $output, string $id ): array { $locations = array(); // Check for gtag.js. @@ -474,11 +479,11 @@ protected static function find_script_locations( string $output, string $id ) : * * @return array Array of warnings. */ - public static function detect_font_redundancy() : array { + public static function detect_font_redundancy(): array { $warnings = array(); // Use cached frontend output. - $output = self::get_frontend_output(); + $output = self::get_frontend_output(); $head_output = $output['head']; // Check enqueued styles. @@ -539,25 +544,25 @@ public static function detect_font_redundancy() : array { foreach ( $fonts_found as $family => $sources ) { if ( count( $sources ) > 1 ) { $warnings[] = array( - 'type' => 'font_redundancy', - 'message' => sprintf( + 'type' => 'font_redundancy', + 'message' => sprintf( /* translators: 1: font family, 2: count */ \__( 'Font family "%1$s" is loaded from %2$d different sources.', 'functionalities' ), $family, count( $sources ) ), - 'location' => sprintf( + 'location' => sprintf( /* translators: %s: source list */ \__( 'Loaded via: %s', 'functionalities' ), implode( ', ', array_unique( $sources ) ) ), - 'reason' => \__( 'Loading the same font from multiple sources wastes bandwidth and increases page load time. Consolidate font loading to a single source.', 'functionalities' ), - 'details' => array( + 'reason' => \__( 'Loading the same font from multiple sources wastes bandwidth and increases page load time. Consolidate font loading to a single source.', 'functionalities' ), + 'details' => array( 'font_family' => $family, 'sources' => $sources, 'count' => count( $sources ), ), - 'detected' => time(), + 'detected' => time(), ); } } @@ -571,11 +576,11 @@ public static function detect_font_redundancy() : array { * @param array $opts Module options. * @return array Array of warnings. */ - public static function detect_inline_css_growth( array $opts ) : array { + public static function detect_inline_css_growth( array $opts ): array { $warnings = array(); // Use cached frontend output. - $output = self::get_frontend_output(); + $output = self::get_frontend_output(); $head_output = $output['head']; // Find all inline styles. @@ -586,11 +591,11 @@ public static function detect_inline_css_growth( array $opts ) : array { ); $total_size = 0; - $sources = array(); + $sources = array(); if ( ! empty( $style_matches[1] ) ) { foreach ( $style_matches[1] as $css ) { - $size = strlen( $css ); + $size = strlen( $css ); $total_size += $size; // Categorize by common patterns. @@ -608,7 +613,7 @@ public static function detect_inline_css_growth( array $opts ) : array { $baseline = \get_option( self::CSS_BASELINE_KEY, array() ); // Calculate size in KB. - $size_kb = round( $total_size / 1024, 1 ); + $size_kb = round( $total_size / 1024, 1 ); $threshold_kb = (float) $opts['inline_css_threshold_kb']; // Store current size. @@ -623,9 +628,9 @@ public static function detect_inline_css_growth( array $opts ) : array { } // Calculate rolling average. - $sizes = array_column( $baseline['history'], 'size' ); + $sizes = array_column( $baseline['history'], 'size' ); $avg_size = count( $sizes ) > 1 ? array_sum( $sizes ) / count( $sizes ) : $total_size; - $avg_kb = round( $avg_size / 1024, 1 ); + $avg_kb = round( $avg_size / 1024, 1 ); \update_option( self::CSS_BASELINE_KEY, $baseline ); @@ -638,54 +643,54 @@ public static function detect_inline_css_growth( array $opts ) : array { } $warnings[] = array( - 'type' => 'inline_css_growth', - 'message' => sprintf( + 'type' => 'inline_css_growth', + 'message' => sprintf( /* translators: 1: current size, 2: threshold */ \__( 'Inline CSS output is %1$s KB (threshold: %2$s KB).', 'functionalities' ), $size_kb, $threshold_kb ), - 'location' => ! empty( $sources_list ) + 'location' => ! empty( $sources_list ) ? sprintf( /* translators: %s: source breakdown */ \__( 'Breakdown: %s', 'functionalities' ), implode( ', ', $sources_list ) ) : \__( 'In page <head> section', 'functionalities' ), - 'reason' => \__( 'Large inline CSS blocks the initial page render and cannot be cached by browsers. Consider moving styles to external stylesheets or reducing unused CSS.', 'functionalities' ), - 'details' => array( - 'current_size_kb' => $size_kb, - 'threshold_kb' => $threshold_kb, - 'average_size_kb' => $avg_kb, - 'sources' => $sources, + 'reason' => \__( 'Large inline CSS blocks the initial page render and cannot be cached by browsers. Consider moving styles to external stylesheets or reducing unused CSS.', 'functionalities' ), + 'details' => array( + 'current_size_kb' => $size_kb, + 'threshold_kb' => $threshold_kb, + 'average_size_kb' => $avg_kb, + 'sources' => $sources, ), - 'detected' => time(), + 'detected' => time(), ); } // Check for sharp increase. if ( count( $sizes ) > 5 && $size_kb > $avg_kb * 1.5 ) { $increase_percent = round( ( $size_kb / $avg_kb - 1 ) * 100 ); - $warnings[] = array( - 'type' => 'inline_css_spike', - 'message' => sprintf( + $warnings[] = array( + 'type' => 'inline_css_spike', + 'message' => sprintf( /* translators: 1: average size, 2: current size */ \__( 'Inline CSS increased from %1$s KB average to %2$s KB.', 'functionalities' ), $avg_kb, $size_kb ), - 'location' => \__( 'In page <head> section <style> tags', 'functionalities' ), - 'reason' => sprintf( + 'location' => \__( 'In page <head> section <style> tags', 'functionalities' ), + 'reason' => sprintf( /* translators: %d: percentage increase */ \__( 'A %d%% spike in inline CSS suggests a plugin, theme update, or content change added significant styles. This may indicate CSS bloat from unused features.', 'functionalities' ), $increase_percent ), - 'details' => array( - 'current_size_kb' => $size_kb, - 'average_size_kb' => $avg_kb, + 'details' => array( + 'current_size_kb' => $size_kb, + 'average_size_kb' => $avg_kb, 'increase_percent' => $increase_percent, ), - 'detected' => time(), + 'detected' => time(), ); } @@ -701,7 +706,7 @@ public static function detect_inline_css_growth( array $opts ) : array { * @since 0.9.2 * @return array Array of warnings. */ - public static function detect_jquery_conflicts() : array { + public static function detect_jquery_conflicts(): array { $warnings = array(); // Check registered scripts. @@ -726,9 +731,9 @@ public static function detect_jquery_conflicts() : array { // Determine source type. if ( strpos( $src, 'ajax.googleapis.com' ) !== false || - strpos( $src, 'cdnjs.cloudflare.com' ) !== false || - strpos( $src, 'code.jquery.com' ) !== false || - strpos( $src, 'cdn.jsdelivr.net' ) !== false ) { + strpos( $src, 'cdnjs.cloudflare.com' ) !== false || + strpos( $src, 'code.jquery.com' ) !== false || + strpos( $src, 'cdn.jsdelivr.net' ) !== false ) { $source_type = 'CDN'; } elseif ( strpos( $src, 'wp-includes' ) !== false ) { $source_type = 'WordPress Core'; @@ -741,7 +746,7 @@ public static function detect_jquery_conflicts() : array { 'source' => $source_type, 'src' => $src, ); - $jquery_sources[] = $source_type; + $jquery_sources[] = $source_type; } } @@ -790,28 +795,28 @@ public static function detect_jquery_conflicts() : array { * @since 0.9.2 * @return array Array of warnings. */ - public static function detect_meta_duplication() : array { + public static function detect_meta_duplication(): array { $warnings = array(); // Use cached frontend output. - $output = self::get_frontend_output(); + $output = self::get_frontend_output(); $head_output = $output['head']; // Meta tags to check for duplicates. $meta_patterns = array( - 'viewport' => array( + 'viewport' => array( 'pattern' => '/]*name=["\']viewport["\'][^>]*>/i', 'label' => 'viewport', ), - 'robots' => array( + 'robots' => array( 'pattern' => '/]*name=["\']robots["\'][^>]*>/i', 'label' => 'robots', ), - 'description' => array( + 'description' => array( 'pattern' => '/]*name=["\']description["\'][^>]*>/i', 'label' => 'description', ), - 'og:title' => array( + 'og:title' => array( 'pattern' => '/]*property=["\']og:title["\'][^>]*>/i', 'label' => 'og:title', ), @@ -819,11 +824,11 @@ public static function detect_meta_duplication() : array { 'pattern' => '/]*property=["\']og:description["\'][^>]*>/i', 'label' => 'og:description', ), - 'og:image' => array( + 'og:image' => array( 'pattern' => '/]*property=["\']og:image["\'][^>]*>/i', 'label' => 'og:image', ), - 'twitter:card' => array( + 'twitter:card' => array( 'pattern' => '/]*name=["\']twitter:card["\'][^>]*>/i', 'label' => 'twitter:card', ), @@ -872,16 +877,19 @@ public static function detect_meta_duplication() : array { * @since 0.9.2 * @return array Array of warnings. */ - public static function detect_rest_exposure() : array { + public static function detect_rest_exposure(): array { $warnings = array(); // Check if users endpoint is accessible. $rest_url = \rest_url( 'wp/v2/users' ); // Make a HEAD request to check accessibility. - $response = \wp_remote_head( $rest_url, array( - 'timeout' => 5, - ) ); + $response = \wp_remote_head( + $rest_url, + array( + 'timeout' => 5, + ) + ); if ( ! \is_wp_error( $response ) ) { $status_code = \wp_remote_retrieve_response_code( $response ); @@ -908,12 +916,15 @@ public static function detect_rest_exposure() : array { } // Check for exposed oEmbed data. - $home_url = \home_url(); - $oembed_url = \rest_url( 'oembed/1.0/embed' ) . '?url=' . urlencode( $home_url ); - - $oembed_response = \wp_remote_get( $oembed_url, array( - 'timeout' => 5, - ) ); + $home_url = \home_url(); + $oembed_url = \rest_url( 'oembed/1.0/embed' ) . '?url=' . rawurlencode( $home_url ); + + $oembed_response = \wp_remote_get( + $oembed_url, + array( + 'timeout' => 5, + ) + ); if ( ! \is_wp_error( $oembed_response ) ) { $body = \wp_remote_retrieve_body( $oembed_response ); @@ -949,24 +960,24 @@ public static function detect_rest_exposure() : array { * @since 0.9.2 * @return array Array of warnings. */ - public static function detect_lazy_load_conflicts() : array { + public static function detect_lazy_load_conflicts(): array { $warnings = array(); // Use cached frontend output. - $output = self::get_frontend_output(); + $output = self::get_frontend_output(); $full_output = $output['full']; // Known lazy loading libraries and patterns. $lazy_patterns = array( - 'native' => array( + 'native' => array( 'pattern' => '/loading=["\']lazy["\']/i', 'name' => 'Native Browser Lazy Loading', ), - 'lazysizes' => array( + 'lazysizes' => array( 'pattern' => '/lazysizes(?:\.min)?\.js/i', 'name' => 'lazysizes.js', ), - 'lozad' => array( + 'lozad' => array( 'pattern' => '/lozad(?:\.min)?\.js/i', 'name' => 'lozad.js', ), @@ -974,23 +985,23 @@ public static function detect_lazy_load_conflicts() : array { 'pattern' => '/vanilla-lazyload|lazyload(?:\.min)?\.js/i', 'name' => 'vanilla-lazyload', ), - 'wp_rocket' => array( + 'wp_rocket' => array( 'pattern' => '/wp-rocket.*lazyload|rocket-lazyload/i', 'name' => 'WP Rocket Lazy Load', ), - 'jetpack' => array( + 'jetpack' => array( 'pattern' => '/jetpack.*lazy|lazy-images/i', 'name' => 'Jetpack Lazy Images', ), - 'a3_lazy' => array( + 'a3_lazy' => array( 'pattern' => '/a3-lazy-load/i', 'name' => 'a3 Lazy Load', ), - 'smush' => array( + 'smush' => array( 'pattern' => '/smush.*lazy|wp-smush-lazy/i', 'name' => 'Smush Lazy Load', ), - 'perfmatters' => array( + 'perfmatters' => array( 'pattern' => '/perfmatters.*lazy/i', 'name' => 'Perfmatters Lazy Load', ), @@ -1050,7 +1061,7 @@ public static function detect_lazy_load_conflicts() : array { * @since 0.9.2 * @return array Array of warnings. */ - public static function detect_mixed_content() : array { + public static function detect_mixed_content(): array { $warnings = array(); // Only check if site is HTTPS. @@ -1059,7 +1070,7 @@ public static function detect_mixed_content() : array { } // Use cached frontend output. - $output = self::get_frontend_output(); + $output = self::get_frontend_output(); $head_output = $output['head']; $http_resources = array(); @@ -1109,19 +1120,22 @@ public static function detect_mixed_content() : array { * @since 0.9.2 * @return array Array of warnings. */ - public static function detect_missing_security_headers() : array { + public static function detect_missing_security_headers(): array { $warnings = array(); // Make a request to the home URL to check headers. - $response = \wp_remote_head( \home_url(), array( - 'timeout' => 5, - ) ); + $response = \wp_remote_head( + \home_url(), + array( + 'timeout' => 5, + ) + ); if ( \is_wp_error( $response ) ) { return $warnings; } - $headers = \wp_remote_retrieve_headers( $response ); + $headers = \wp_remote_retrieve_headers( $response ); $headers_array = $headers instanceof \Requests_Utility_CaseInsensitiveDictionary ? $headers->getAll() : (array) $headers; @@ -1131,9 +1145,9 @@ public static function detect_missing_security_headers() : array { // Check for critical security headers. $security_headers = array( - 'x-content-type-options' => 'X-Content-Type-Options', - 'x-frame-options' => 'X-Frame-Options', - 'x-xss-protection' => 'X-XSS-Protection', + 'x-content-type-options' => 'X-Content-Type-Options', + 'x-frame-options' => 'X-Frame-Options', + 'x-xss-protection' => 'X-XSS-Protection', 'strict-transport-security' => 'Strict-Transport-Security (HSTS)', ); @@ -1169,9 +1183,9 @@ public static function detect_missing_security_headers() : array { * @since 0.9.2 * @return array Array of warnings. */ - public static function detect_debug_exposure() : array { + public static function detect_debug_exposure(): array { $warnings = array(); - $issues = array(); + $issues = array(); // Check WP_DEBUG. if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { @@ -1219,9 +1233,9 @@ public static function detect_debug_exposure() : array { * @since 0.9.2 * @return array Array of warnings. */ - public static function detect_cron_issues() : array { + public static function detect_cron_issues(): array { $warnings = array(); - $issues = array(); + $issues = array(); // Check if WP-Cron is disabled. if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { @@ -1229,9 +1243,9 @@ public static function detect_cron_issues() : array { } // Check for stuck cron jobs (jobs more than 1 hour overdue). - $crons = \_get_cron_array(); + $crons = \_get_cron_array(); $current_time = time(); - $stuck_jobs = 0; + $stuck_jobs = 0; if ( is_array( $crons ) ) { foreach ( $crons as $timestamp => $cron ) { @@ -1281,7 +1295,7 @@ public static function detect_cron_issues() : array { * * @return array Detected assumptions. */ - public static function get_detected_assumptions() : array { + public static function get_detected_assumptions(): array { return (array) \get_option( self::OPTION_KEY, array() ); } @@ -1290,7 +1304,7 @@ public static function get_detected_assumptions() : array { * * @return array Ignored assumptions with expiry timestamps. */ - public static function get_ignored_assumptions() : array { + public static function get_ignored_assumptions(): array { return (array) \get_option( self::IGNORED_KEY, array() ); } @@ -1300,7 +1314,7 @@ public static function get_ignored_assumptions() : array { * @param array $warning Warning data. * @return string Hash. */ - public static function get_warning_hash( array $warning ) : string { + public static function get_warning_hash( array $warning ): string { $key_parts = array( $warning['type'] ); if ( isset( $warning['details']['schema_type'] ) ) { @@ -1331,7 +1345,7 @@ public static function get_warning_hash( array $warning ) : string { * * @return void */ - public static function ajax_ignore_assumption() : void { + public static function ajax_ignore_assumption(): void { \check_ajax_referer( 'functionalities_assumptions', 'nonce' ); if ( ! \current_user_can( 'manage_options' ) ) { @@ -1344,12 +1358,13 @@ public static function ajax_ignore_assumption() : void { \wp_send_json_error( array( 'message' => \__( 'Invalid request.', 'functionalities' ) ) ); } - $ignored = self::get_ignored_assumptions(); + $ignored = self::get_ignored_assumptions(); $ignored[ $hash ] = array( - 'expires' => PHP_INT_MAX, // Never expires. + 'expires' => PHP_INT_MAX, // Never expires. 'ignored_at' => time(), ); \update_option( self::IGNORED_KEY, $ignored ); + self::record_audit( 'ignore', $hash ); \wp_send_json_success(); } @@ -1359,7 +1374,7 @@ public static function ajax_ignore_assumption() : void { * * @return void */ - public static function ajax_acknowledge_assumption() : void { + public static function ajax_acknowledge_assumption(): void { \check_ajax_referer( 'functionalities_assumptions', 'nonce' ); if ( ! \current_user_can( 'manage_options' ) ) { @@ -1374,10 +1389,14 @@ public static function ajax_acknowledge_assumption() : void { // Remove from detected list. $detected = self::get_detected_assumptions(); - $detected = array_filter( $detected, function( $warning ) use ( $hash ) { - return self::get_warning_hash( $warning ) !== $hash; - } ); + $detected = array_filter( + $detected, + function ( $warning ) use ( $hash ) { + return self::get_warning_hash( $warning ) !== $hash; + } + ); \update_option( self::OPTION_KEY, array_values( $detected ) ); + self::record_audit( 'acknowledge', $hash ); \wp_send_json_success(); } @@ -1387,7 +1406,7 @@ public static function ajax_acknowledge_assumption() : void { * * @return void */ - public static function ajax_snooze_assumption() : void { + public static function ajax_snooze_assumption(): void { \check_ajax_referer( 'functionalities_assumptions', 'nonce' ); if ( ! \current_user_can( 'manage_options' ) ) { @@ -1401,12 +1420,13 @@ public static function ajax_snooze_assumption() : void { \wp_send_json_error( array( 'message' => \__( 'Invalid request.', 'functionalities' ) ) ); } - $ignored = self::get_ignored_assumptions(); + $ignored = self::get_ignored_assumptions(); $ignored[ $hash ] = array( - 'expires' => time() + ( $days * DAY_IN_SECONDS ), + 'expires' => time() + ( $days * DAY_IN_SECONDS ), 'snoozed_at' => time(), ); \update_option( self::IGNORED_KEY, $ignored ); + self::record_audit( 'snooze', $hash, array( 'days' => $days ) ); \wp_send_json_success(); } @@ -1416,14 +1436,17 @@ public static function ajax_snooze_assumption() : void { * * @return int Warning count. */ - public static function get_warning_count() : int { + public static function get_warning_count(): int { $detected = self::get_detected_assumptions(); - $ignored = self::get_ignored_assumptions(); + $ignored = self::get_ignored_assumptions(); - $active = array_filter( $detected, function( $warning ) use ( $ignored ) { - $hash = self::get_warning_hash( $warning ); - return ! isset( $ignored[ $hash ] ) || $ignored[ $hash ]['expires'] < time(); - } ); + $active = array_filter( + $detected, + function ( $warning ) use ( $ignored ) { + $hash = self::get_warning_hash( $warning ); + return ! isset( $ignored[ $hash ] ) || $ignored[ $hash ]['expires'] < time(); + } + ); return count( $active ); } @@ -1433,7 +1456,7 @@ public static function get_warning_count() : int { * * @return array Detected warnings. */ - public static function force_run_detection() : array { + public static function force_run_detection(): array { // Clear the cache to ensure fresh detection. self::$frontend_output_cache = null; @@ -1443,6 +1466,28 @@ public static function force_run_detection() : array { return self::get_detected_assumptions(); } + /** + * Append bounded action metadata without storing page content or URLs. + * + * @param string $action Action name. + * @param string $hash Finding hash. + * @param array $meta Additional non-content metadata. + * @return void + */ + private static function record_audit( string $action, string $hash, array $meta = array() ): void { + $audit = (array) \get_option( 'functionalities_assumption_audit', array() ); + $audit[] = array_merge( + array( + 'action' => $action, + 'hash' => $hash, + 'user_id' => \get_current_user_id(), + 'timestamp' => time(), + ), + $meta + ); + \update_option( 'functionalities_assumption_audit', array_slice( $audit, -100 ), false ); + } + /** * Safely capture frontend output (wp_head and wp_footer). * @@ -1451,12 +1496,12 @@ public static function force_run_detection() : array { * * @return array Array with 'head' and 'footer' keys. */ - protected static function get_frontend_output() : array { + protected static function get_frontend_output(): array { if ( null !== self::$frontend_output_cache ) { return self::$frontend_output_cache; } - $head_output = ''; + $head_output = ''; $footer_output = ''; // Suppress any output during capture. @@ -1506,7 +1551,7 @@ protected static function get_frontend_output() : array { * * @return void */ - public static function clear_cache() : void { + public static function clear_cache(): void { self::$frontend_output_cache = null; } } diff --git a/includes/features/class-block-cleanup.php b/includes/features/class-block-cleanup.php index 7c99726..09a4f69 100644 --- a/includes/features/class-block-cleanup.php +++ b/includes/features/class-block-cleanup.php @@ -98,7 +98,7 @@ class Block_Cleanup { * * @return void */ - public static function init() : void { + public static function init(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -165,12 +165,12 @@ public static function init() : void { * * @return array Options array. */ - protected static function get_options() : array { + protected static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( + $defaults = array( 'enabled' => false, 'remove_heading_block_class' => false, 'remove_list_block_class' => false, @@ -186,7 +186,7 @@ protected static function get_options() : array { 'remove_media_text_block_class' => false, 'custom_classes_to_remove' => '', ); - $opts = (array) \get_option( 'functionalities_block_cleanup', $defaults ); + $opts = (array) \get_option( 'functionalities_block_cleanup', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -205,7 +205,7 @@ protected static function get_options() : array { * @param string $content The post content to filter. * @return string The filtered content with block classes removed. */ - public static function filter_content_cleanup( string $content ) : string { + public static function filter_content_cleanup( string $content ): string { // Skip in admin, feeds, and REST requests. if ( \is_admin() || \is_feed() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return $content; @@ -386,7 +386,7 @@ public static function filter_content_cleanup( string $content ) : string { * @param string $value Raw string value to embed in an XPath expression. * @return string Quoted XPath literal (e.g. `"foo"`, `'foo"bar'`, or `concat('a', "'", 'b')`). */ - protected static function xpath_string_literal( string $value ) : string { + protected static function xpath_string_literal( string $value ): string { if ( false === strpos( $value, "'" ) ) { return "'" . $value . "'"; } @@ -407,7 +407,7 @@ protected static function xpath_string_literal( string $value ) : string { return 'concat(' . implode( ',', $parts ) . ')'; } - protected static function strip_class_from_nodes( $nodes, string $class ) : void { + protected static function strip_class_from_nodes( $nodes, string $class ): void { if ( ! ( $nodes instanceof \DOMNodeList ) ) { return; } diff --git a/includes/features/class-components.php b/includes/features/class-components.php index 06a7ecb..a551122 100644 --- a/includes/features/class-components.php +++ b/includes/features/class-components.php @@ -103,7 +103,7 @@ class Components { * * @return void */ - public static function init() : void { + public static function init(): void { // Output CSS in footer (both frontend and admin). \add_action( 'wp_footer', array( __CLASS__, 'print_footer_link' ), 90 ); \add_action( 'admin_footer', array( __CLASS__, 'print_footer_link' ), 90 ); @@ -132,17 +132,17 @@ public static function init() : void { * * @return array Options array. */ - protected static function get_options() : array { + protected static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( + $defaults = array( 'enabled' => false, 'items' => array(), 'css_ver' => '', ); - $opts = (array) \get_option( 'functionalities_components', $defaults ); + $opts = (array) \get_option( 'functionalities_components', $defaults ); self::$options = array_merge( $defaults, $opts ); // Load default components if none configured. @@ -214,7 +214,7 @@ public static function add_editor_settings_components( $settings ) { * * @return void */ - public static function print_footer_link() : void { + public static function print_footer_link(): void { $opts = self::get_options(); /** @@ -280,7 +280,7 @@ public static function print_footer_link() : void { * @param array $items Array of component definitions. * @return string Generated CSS string. */ - protected static function build_css( array $items ) : string { + protected static function build_css( array $items ): string { $parts = array(); foreach ( $items as $item ) { @@ -325,7 +325,7 @@ protected static function build_css( array $items ) : string { * @param string $css The CSS content to write. * @return array|null File info array on success, null on failure. */ - protected static function ensure_css_file( string $css ) : ?array { + protected static function ensure_css_file( string $css ): ?array { if ( null !== self::$css_file_info ) { return self::$css_file_info; } @@ -341,11 +341,11 @@ protected static function ensure_css_file( string $css ) : ?array { // Avoid md5_file on every request. Check if file exists first. $file_exists = is_file( $file ); - + // If we have a cached version in options, compare with current hash. - $opts = self::get_options(); + $opts = self::get_options(); $needs_update = ! $file_exists || ( ! empty( $opts['css_ver'] ) && $opts['css_ver'] !== $hash ); - + // If no cached version in options but file exists, we might still want to check once. if ( ! $needs_update && $file_exists && empty( $opts['css_ver'] ) ) { if ( md5_file( $file ) !== $hash ) { @@ -366,15 +366,8 @@ protected static function ensure_css_file( string $css ) : ?array { } global $wp_filesystem; - if ( ! WP_Filesystem() ) { - $bytes = file_put_contents( $file, $sanitized_css ); - if ( false === $bytes ) { - return null; - } - } else { - if ( ! $wp_filesystem->put_contents( $file, $sanitized_css, FS_CHMOD_FILE ) ) { - return null; - } + if ( ! WP_Filesystem() || ! $wp_filesystem->put_contents( $file, $sanitized_css, FS_CHMOD_FILE ) ) { + return null; } // Update the version in options to avoid future checks. @@ -408,7 +401,7 @@ protected static function ensure_css_file( string $css ) : ?array { * @param mixed $value New option value. * @return void */ - public static function on_option_update( $old_value, $value ) : void { + public static function on_option_update( $old_value, $value ): void { $opts = is_array( $value ) ? $value : array(); if ( empty( $opts['enabled'] ) || empty( $opts['items'] ) || ! is_array( $opts['items'] ) ) { diff --git a/includes/features/class-content-regression.php b/includes/features/class-content-regression.php index 2fc8e55..f3ae0ab 100644 --- a/includes/features/class-content-regression.php +++ b/includes/features/class-content-regression.php @@ -43,7 +43,7 @@ class Content_Regression { * * @return void */ - public static function init() : void { + public static function init(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -81,39 +81,39 @@ public static function init() : void { * * @return array Options array. */ - public static function get_options() : array { + public static function get_options(): array { if ( null !== self::$options ) { return self::$options; } $defaults = array( - 'enabled' => false, - 'post_types' => array( 'post', 'page' ), + 'enabled' => false, + 'post_types' => array( 'post', 'page' ), // Internal link detection. - 'link_drop_enabled' => true, - 'link_drop_percent' => 30, - 'link_drop_absolute' => 3, - 'exclude_nofollow_links' => false, + 'link_drop_enabled' => true, + 'link_drop_percent' => 30, + 'link_drop_absolute' => 3, + 'exclude_nofollow_links' => false, // Word count detection. - 'word_count_enabled' => true, - 'word_count_drop_percent' => 35, - 'word_count_min_age_days' => 30, - 'word_count_compare_average' => false, - 'exclude_shortcodes' => false, + 'word_count_enabled' => true, + 'word_count_drop_percent' => 35, + 'word_count_min_age_days' => 30, + 'word_count_compare_average' => false, + 'exclude_shortcodes' => false, // Heading detection. - 'heading_enabled' => true, - 'detect_missing_h1' => true, - 'detect_multiple_h1' => true, - 'detect_skipped_levels' => true, - 'title_is_h1' => true, // Consider post title as H1 (themes render it as H1). + 'heading_enabled' => true, + 'detect_missing_h1' => true, + 'detect_multiple_h1' => true, + 'detect_skipped_levels' => true, + 'title_is_h1' => true, // Consider post title as H1 (themes render it as H1). // Image detection. - 'detect_missing_alt' => true, + 'detect_missing_alt' => true, // Snapshot settings. - 'snapshot_rolling_count' => 5, + 'snapshot_rolling_count' => 5, // UI settings. - 'show_post_column' => true, + 'show_post_column' => true, ); - $opts = (array) \get_option( 'functionalities_content_regression', $defaults ); + $opts = (array) \get_option( 'functionalities_content_regression', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -124,7 +124,7 @@ public static function get_options() : array { * @param int $post_id Post ID. * @return array Per-post settings. */ - public static function get_post_settings( int $post_id ) : array { + public static function get_post_settings( int $post_id ): array { $defaults = array( 'detection_disabled' => false, 'is_short_form' => false, @@ -143,9 +143,9 @@ public static function get_post_settings( int $post_id ) : array { * @param array $settings Settings to save. * @return void */ - public static function update_post_settings( int $post_id, array $settings ) : void { + public static function update_post_settings( int $post_id, array $settings ): void { $current = self::get_post_settings( $post_id ); - $merged = array_merge( $current, $settings ); + $merged = array_merge( $current, $settings ); \update_post_meta( $post_id, self::POST_SETTINGS_KEY, $merged ); } @@ -157,7 +157,7 @@ public static function update_post_settings( int $post_id, array $settings ) : v * @param bool $update Whether this is an update. * @return void */ - public static function on_save_post( int $post_id, \WP_Post $post, bool $update ) : void { + public static function on_save_post( int $post_id, \WP_Post $post, bool $update ): void { // Skip autosaves and revisions. if ( \wp_is_post_autosave( $post_id ) || \wp_is_post_revision( $post_id ) ) { return; @@ -193,25 +193,22 @@ public static function on_save_post( int $post_id, \WP_Post $post, bool $update * @param \WP_Post $post Post object. * @return array Snapshot data. */ - public static function capture_snapshot( \WP_Post $post ) : array { + public static function capture_snapshot( \WP_Post $post ): array { $content = $post->post_content; - $opts = self::get_options(); + $opts = self::get_options(); // Get rendered content for accurate parsing - with error handling. $rendered = ''; try { // Remove problematic filters temporarily. $current_post = $GLOBALS['post'] ?? null; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- The content filter expects the target post globally; restored in finally. $GLOBALS['post'] = $post; // Apply content filters with error suppression. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Applying core WordPress filter. $rendered = \apply_filters( 'the_content', $content ); - // Restore original post. - if ( null !== $current_post ) { - $GLOBALS['post'] = $current_post; - } } catch ( \Exception $e ) { // If content filter fails, use raw content. $rendered = $content; @@ -219,6 +216,13 @@ public static function capture_snapshot( \WP_Post $post ) : array { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging only when WP_DEBUG is enabled. error_log( 'Functionalities Content Regression: Error rendering content - ' . $e->getMessage() ); } + } finally { + if ( null !== $current_post ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the pre-filter global. + $GLOBALS['post'] = $current_post; + } else { + unset( $GLOBALS['post'] ); + } } // Ensure we have valid content to analyze. @@ -227,18 +231,18 @@ public static function capture_snapshot( \WP_Post $post ) : array { } // Parse the content with error handling. - $links = self::parse_links( $rendered, $opts ); + $links = self::parse_links( $rendered, $opts ); $word_count = self::count_words( $content, $opts ); - $headings = self::parse_headings( $content ); + $headings = self::parse_headings( $content ); return array( - 'internal_link_count' => $links['internal'], - 'external_link_count' => $links['external'], - 'word_count' => $word_count, - 'heading_map' => $headings['map'], - 'h1_count' => $headings['h1_count'], - 'timestamp' => time(), - 'is_stable_version' => true, + 'internal_link_count' => $links['internal'], + 'external_link_count' => $links['external'], + 'word_count' => $word_count, + 'heading_map' => $headings['map'], + 'h1_count' => $headings['h1_count'], + 'timestamp' => time(), + 'is_stable_version' => true, ); } @@ -249,20 +253,23 @@ public static function capture_snapshot( \WP_Post $post ) : array { * @param array $opts Module options. * @return array Link counts. */ - public static function parse_links( string $content, array $opts ) : array { + public static function parse_links( string $content, array $opts ): array { $internal = 0; $external = 0; if ( empty( $content ) ) { - return array( 'internal' => 0, 'external' => 0 ); + return array( + 'internal' => 0, + 'external' => 0, + ); } $site_host = (string) \wp_parse_url( \home_url(), PHP_URL_HOST ); // Use DOMDocument for reliable parsing. $libxml_previous = libxml_use_internal_errors( true ); - $dom = new \DOMDocument( '1.0', 'UTF-8' ); - $html = '
    ' . $content . '
    '; + $dom = new \DOMDocument( '1.0', 'UTF-8' ); + $html = '
    ' . $content . '
    '; $dom->loadHTML( '' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); $xpath = new \DOMXPath( $dom ); @@ -278,7 +285,7 @@ public static function parse_links( string $content, array $opts ) : array { ); // Get all links. - $all_links = $xpath->query( '//a[@href]' ); + $all_links = $xpath->query( '//a[@href]' ); $excluded_links = array(); // Build list of excluded links. @@ -299,7 +306,7 @@ public static function parse_links( string $content, array $opts ) : array { } $href = (string) $link->getAttribute( 'href' ); - $rel = (string) $link->getAttribute( 'rel' ); + $rel = (string) $link->getAttribute( 'rel' ); // Skip empty hrefs and anchors. if ( empty( $href ) || '#' === $href[0] ) { @@ -344,7 +351,7 @@ public static function parse_links( string $content, array $opts ) : array { * @param string $site_host Site hostname. * @return bool True if internal. */ - protected static function is_internal_url( string $href, string $site_host ) : bool { + protected static function is_internal_url( string $href, string $site_host ): bool { $href = trim( $href ); // Relative URLs are internal. @@ -372,7 +379,7 @@ protected static function is_internal_url( string $href, string $site_host ) : b * @param array $opts Module options. * @return int Word count. */ - public static function count_words( string $content, array $opts ) : int { + public static function count_words( string $content, array $opts ): int { // Optionally remove shortcodes. if ( ! empty( $opts['exclude_shortcodes'] ) ) { $content = \strip_shortcodes( $content ); @@ -402,12 +409,15 @@ public static function count_words( string $content, array $opts ) : int { * @param string $content Raw post content (block editor or classic). * @return array Heading data with map and h1_count. */ - public static function parse_headings( string $content ) : array { + public static function parse_headings( string $content ): array { $heading_map = array(); - $h1_count = 0; + $h1_count = 0; if ( empty( $content ) ) { - return array( 'map' => $heading_map, 'h1_count' => $h1_count ); + return array( + 'map' => $heading_map, + 'h1_count' => $h1_count, + ); } // Try to parse blocks first (for block editor content). @@ -435,10 +445,10 @@ public static function parse_headings( string $content ) : array { * @param int $h1_count Reference to H1 count. * @return void */ - protected static function extract_headings_from_blocks( array $blocks, array &$heading_map, int &$h1_count ) : void { + protected static function extract_headings_from_blocks( array $blocks, array &$heading_map, int &$h1_count ): void { foreach ( $blocks as $block ) { if ( 'core/heading' === $block['blockName'] ) { - $level = isset( $block['attrs']['level'] ) ? (int) $block['attrs']['level'] : 2; + $level = isset( $block['attrs']['level'] ) ? (int) $block['attrs']['level'] : 2; $heading_map[] = $level; if ( 1 === $level ) { ++$h1_count; @@ -460,21 +470,21 @@ protected static function extract_headings_from_blocks( array $blocks, array &$h * @param int $h1_count Reference to H1 count. * @return void */ - protected static function extract_headings_from_html( string $html, array &$heading_map, int &$h1_count ) : void { + protected static function extract_headings_from_html( string $html, array &$heading_map, int &$h1_count ): void { if ( empty( $html ) ) { return; } $libxml_previous = libxml_use_internal_errors( true ); - $dom = new \DOMDocument( '1.0', 'UTF-8' ); + $dom = new \DOMDocument( '1.0', 'UTF-8' ); $dom->loadHTML( '' . $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); - $xpath = new \DOMXPath( $dom ); + $xpath = new \DOMXPath( $dom ); $headings = $xpath->query( '//h1|//h2|//h3|//h4|//h5|//h6' ); if ( $headings instanceof \DOMNodeList ) { foreach ( $headings as $heading ) { - $level = (int) substr( $heading->nodeName, 1 ); + $level = (int) substr( $heading->nodeName, 1 ); $heading_map[] = $level; if ( 1 === $level ) { ++$h1_count; @@ -494,7 +504,7 @@ protected static function extract_headings_from_html( string $html, array &$head * @param array $opts Module options. * @return void */ - protected static function store_snapshot( int $post_id, array $snapshot, array $opts ) : void { + protected static function store_snapshot( int $post_id, array $snapshot, array $opts ): void { $existing = \get_post_meta( $post_id, self::META_KEY, true ); if ( ! is_array( $existing ) ) { @@ -508,7 +518,7 @@ protected static function store_snapshot( int $post_id, array $snapshot, array $ $existing['snapshots'][] = $snapshot; // Keep only last N snapshots. - $keep_count = max( 1, (int) $opts['snapshot_rolling_count'] ); + $keep_count = max( 1, (int) $opts['snapshot_rolling_count'] ); $existing['snapshots'] = array_slice( $existing['snapshots'], -$keep_count ); // Calculate rolling average. @@ -523,16 +533,19 @@ protected static function store_snapshot( int $post_id, array $snapshot, array $ * @param array $snapshots Array of snapshots. * @return array Rolling average values. */ - protected static function calculate_rolling_average( array $snapshots ) : array { - $stable_snapshots = array_filter( $snapshots, function( $s ) { - return ! empty( $s['is_stable_version'] ); - } ); + protected static function calculate_rolling_average( array $snapshots ): array { + $stable_snapshots = array_filter( + $snapshots, + function ( $s ) { + return ! empty( $s['is_stable_version'] ); + } + ); if ( empty( $stable_snapshots ) ) { return array(); } - $count = count( $stable_snapshots ); + $count = count( $stable_snapshots ); $totals = array( 'internal_link_count' => 0, 'external_link_count' => 0, @@ -542,7 +555,7 @@ protected static function calculate_rolling_average( array $snapshots ) : array foreach ( $stable_snapshots as $snapshot ) { $totals['internal_link_count'] += (int) ( $snapshot['internal_link_count'] ?? 0 ); $totals['external_link_count'] += (int) ( $snapshot['external_link_count'] ?? 0 ); - $totals['word_count'] += (int) ( $snapshot['word_count'] ?? 0 ); + $totals['word_count'] += (int) ( $snapshot['word_count'] ?? 0 ); } return array( @@ -558,7 +571,7 @@ protected static function calculate_rolling_average( array $snapshots ) : array * @param int $post_id Post ID. * @return array|null Last stable snapshot or null. */ - public static function get_last_stable_snapshot( int $post_id ) : ?array { + public static function get_last_stable_snapshot( int $post_id ): ?array { $data = \get_post_meta( $post_id, self::META_KEY, true ); if ( ! is_array( $data ) || empty( $data['snapshots'] ) ) { @@ -582,7 +595,7 @@ public static function get_last_stable_snapshot( int $post_id ) : ?array { * @param int $post_id Post ID. * @return array|null Rolling average or null. */ - public static function get_rolling_average( int $post_id ) : ?array { + public static function get_rolling_average( int $post_id ): ?array { $data = \get_post_meta( $post_id, self::META_KEY, true ); if ( ! is_array( $data ) || empty( $data['rolling_average'] ) ) { @@ -598,9 +611,9 @@ public static function get_rolling_average( int $post_id ) : ?array { * @param int $post_id Post ID. * @return array Array of warnings (empty if no issues). */ - public static function detect_regressions( int $post_id ) : array { - $warnings = array(); - $opts = self::get_options(); + public static function detect_regressions( int $post_id ): array { + $warnings = array(); + $opts = self::get_options(); $post_settings = self::get_post_settings( $post_id ); // Check if detection is disabled for this post. @@ -641,7 +654,7 @@ public static function detect_regressions( int $post_id ) : array { // Check heading structure. if ( ! empty( $opts['heading_enabled'] ) ) { $heading_warnings = self::check_heading_structure( $current, $opts, $post ); - $warnings = array_merge( $warnings, $heading_warnings ); + $warnings = array_merge( $warnings, $heading_warnings ); } // Check for images missing alt text. @@ -663,8 +676,8 @@ public static function detect_regressions( int $post_id ) : array { * @param array $opts Module options. * @return array|null Warning data or null. */ - protected static function check_link_drop( array $current, array $baseline, array $opts ) : ?array { - $current_count = (int) ( $current['internal_link_count'] ?? 0 ); + protected static function check_link_drop( array $current, array $baseline, array $opts ): ?array { + $current_count = (int) ( $current['internal_link_count'] ?? 0 ); $baseline_count = (int) ( $baseline['internal_link_count'] ?? 0 ); // No warning if baseline had no links. @@ -672,25 +685,25 @@ protected static function check_link_drop( array $current, array $baseline, arra return null; } - $drop = $baseline_count - $current_count; + $drop = $baseline_count - $current_count; $drop_percent = ( $drop / $baseline_count ) * 100; - $threshold_percent = (float) $opts['link_drop_percent']; + $threshold_percent = (float) $opts['link_drop_percent']; $threshold_absolute = (int) $opts['link_drop_absolute']; // Trigger if drop exceeds percentage OR absolute threshold. if ( $drop_percent >= $threshold_percent || $drop >= $threshold_absolute ) { return array( - 'type' => 'link_drop', - 'severity' => 'warning', - 'message' => sprintf( + 'type' => 'link_drop', + 'severity' => 'warning', + 'message' => sprintf( /* translators: 1: previous link count, 2: current link count */ \__( 'This update reduced internal links from %1$d to %2$d compared to the previous version.', 'functionalities' ), $baseline_count, $current_count ), - 'before' => $baseline_count, - 'after' => $current_count, + 'before' => $baseline_count, + 'after' => $current_count, 'baseline_timestamp' => $baseline['timestamp'] ?? 0, ); } @@ -708,13 +721,13 @@ protected static function check_link_drop( array $current, array $baseline, arra * @param array $post_settings Per-post settings. * @return array|null Warning data or null. */ - protected static function check_word_count_drop( \WP_Post $post, array $current, array $baseline, array $opts, array $post_settings ) : ?array { + protected static function check_word_count_drop( \WP_Post $post, array $current, array $baseline, array $opts, array $post_settings ): ?array { // Skip if marked as short-form. if ( ! empty( $post_settings['is_short_form'] ) ) { return null; } - $current_count = (int) ( $current['word_count'] ?? 0 ); + $current_count = (int) ( $current['word_count'] ?? 0 ); $baseline_count = (int) ( $baseline['word_count'] ?? 0 ); // No warning if baseline had no words. @@ -724,31 +737,31 @@ protected static function check_word_count_drop( \WP_Post $post, array $current, // Check post age requirement. $min_age_days = (int) $opts['word_count_min_age_days']; - $post_date = strtotime( $post->post_date_gmt ); - $age_days = ( time() - $post_date ) / DAY_IN_SECONDS; + $post_date = strtotime( $post->post_date_gmt ); + $age_days = ( time() - $post_date ) / DAY_IN_SECONDS; if ( $age_days < $min_age_days ) { return null; } // Calculate drop percentage. - $drop = $baseline_count - $current_count; + $drop = $baseline_count - $current_count; $drop_percent = ( $drop / $baseline_count ) * 100; $threshold_percent = (float) $opts['word_count_drop_percent']; if ( $drop_percent >= $threshold_percent ) { return array( - 'type' => 'word_count_drop', - 'severity' => 'warning', - 'message' => sprintf( + 'type' => 'word_count_drop', + 'severity' => 'warning', + 'message' => sprintf( /* translators: %d: percentage drop */ \__( 'This post is %d%% shorter than its previous published version.', 'functionalities' ), round( $drop_percent ) ), - 'before' => $baseline_count, - 'after' => $current_count, - 'drop_percent' => round( $drop_percent ), + 'before' => $baseline_count, + 'after' => $current_count, + 'drop_percent' => round( $drop_percent ), 'baseline_timestamp' => $baseline['timestamp'] ?? 0, ); } @@ -764,14 +777,14 @@ protected static function check_word_count_drop( \WP_Post $post, array $current, * @param \WP_Post $post Post object. * @return array Array of warnings. */ - protected static function check_heading_structure( array $current, array $opts, \WP_Post $post ) : array { - $warnings = array(); - $heading_map = $current['heading_map'] ?? array(); + protected static function check_heading_structure( array $current, array $opts, \WP_Post $post ): array { + $warnings = array(); + $heading_map = $current['heading_map'] ?? array(); $h1_count_in_content = $current['h1_count'] ?? 0; // Check if post title should count as H1 (most themes render title as H1). $title_is_h1 = ! empty( $opts['title_is_h1'] ); - $has_title = ! empty( $post->post_title ); + $has_title = ! empty( $post->post_title ); // Effective H1 count includes title if enabled. $effective_h1_count = $h1_count_in_content; @@ -826,7 +839,7 @@ protected static function check_heading_structure( array $current, array $opts, if ( ! empty( $opts['detect_skipped_levels'] ) && ! empty( $heading_map ) ) { // If title is H1, content should start with H2. $expected_start = ( $title_is_h1 && $has_title ) ? 2 : 1; - $first_heading = reset( $heading_map ); + $first_heading = reset( $heading_map ); // Check if first heading skips from title (H1) or start. if ( $first_heading > $expected_start ) { @@ -850,7 +863,7 @@ protected static function check_heading_structure( array $current, array $opts, } else { // Check for skipped levels in content. $prev_level = $first_heading; - $rest = array_slice( $heading_map, 1 ); + $rest = array_slice( $heading_map, 1 ); foreach ( $rest as $level ) { if ( $level > $prev_level + 1 ) { $warnings[] = array( @@ -881,7 +894,7 @@ protected static function check_heading_structure( array $current, array $opts, * @param \WP_Post $post Post object. * @return array|null Warning data or null. */ - protected static function check_missing_alt_text( \WP_Post $post ) : ?array { + protected static function check_missing_alt_text( \WP_Post $post ): ?array { $content = $post->post_content; // Find all img tags. @@ -889,7 +902,7 @@ protected static function check_missing_alt_text( \WP_Post $post ) : ?array { return null; } - $missing_alt = 0; + $missing_alt = 0; $total_images = count( $matches[0] ); foreach ( $matches[0] as $img ) { @@ -927,70 +940,86 @@ protected static function check_missing_alt_text( \WP_Post $post ) : ?array { * * @return void */ - public static function register_rest_routes() : void { - \register_rest_route( 'functionalities/v1', '/regression/(?P\d+)', array( - 'methods' => 'GET', - 'callback' => array( __CLASS__, 'rest_get_regression_status' ), - 'permission_callback' => function() { - return \current_user_can( 'edit_posts' ); - }, - 'args' => array( - 'id' => array( - 'required' => true, - 'validate_callback' => function( $param ) { - return is_numeric( $param ); - }, + public static function register_rest_routes(): void { + \register_rest_route( + 'functionalities/v1', + '/regression/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( __CLASS__, 'rest_get_regression_status' ), + 'permission_callback' => function () { + return \current_user_can( 'edit_posts' ); + }, + 'args' => array( + 'id' => array( + 'required' => true, + 'validate_callback' => function ( $param ) { + return is_numeric( $param ); + }, + ), ), - ), - ) ); - - \register_rest_route( 'functionalities/v1', '/regression/(?P\d+)/mark-intentional', array( - 'methods' => 'POST', - 'callback' => array( __CLASS__, 'rest_mark_intentional' ), - 'permission_callback' => function() { - return \current_user_can( 'edit_posts' ); - }, - 'args' => array( - 'id' => array( - 'required' => true, - 'validate_callback' => function( $param ) { - return is_numeric( $param ); - }, + ) + ); + + \register_rest_route( + 'functionalities/v1', + '/regression/(?P\d+)/mark-intentional', + array( + 'methods' => 'POST', + 'callback' => array( __CLASS__, 'rest_mark_intentional' ), + 'permission_callback' => function () { + return \current_user_can( 'edit_posts' ); + }, + 'args' => array( + 'id' => array( + 'required' => true, + 'validate_callback' => function ( $param ) { + return is_numeric( $param ); + }, + ), ), - ), - ) ); - - \register_rest_route( 'functionalities/v1', '/regression/(?P\d+)/reset-baseline', array( - 'methods' => 'POST', - 'callback' => array( __CLASS__, 'rest_reset_baseline' ), - 'permission_callback' => function() { - return \current_user_can( 'edit_posts' ); - }, - 'args' => array( - 'id' => array( - 'required' => true, - 'validate_callback' => function( $param ) { - return is_numeric( $param ); - }, + ) + ); + + \register_rest_route( + 'functionalities/v1', + '/regression/(?P\d+)/reset-baseline', + array( + 'methods' => 'POST', + 'callback' => array( __CLASS__, 'rest_reset_baseline' ), + 'permission_callback' => function () { + return \current_user_can( 'edit_posts' ); + }, + 'args' => array( + 'id' => array( + 'required' => true, + 'validate_callback' => function ( $param ) { + return is_numeric( $param ); + }, + ), ), - ), - ) ); - - \register_rest_route( 'functionalities/v1', '/regression/(?P\d+)/settings', array( - 'methods' => 'POST', - 'callback' => array( __CLASS__, 'rest_update_post_settings' ), - 'permission_callback' => function() { - return \current_user_can( 'edit_posts' ); - }, - 'args' => array( - 'id' => array( - 'required' => true, - 'validate_callback' => function( $param ) { - return is_numeric( $param ); - }, + ) + ); + + \register_rest_route( + 'functionalities/v1', + '/regression/(?P\d+)/settings', + array( + 'methods' => 'POST', + 'callback' => array( __CLASS__, 'rest_update_post_settings' ), + 'permission_callback' => function () { + return \current_user_can( 'edit_posts' ); + }, + 'args' => array( + 'id' => array( + 'required' => true, + 'validate_callback' => function ( $param ) { + return is_numeric( $param ); + }, + ), ), - ), - ) ); + ) + ); } /** @@ -999,9 +1028,9 @@ public static function register_rest_routes() : void { * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response. */ - public static function rest_get_regression_status( \WP_REST_Request $request ) : \WP_REST_Response { + public static function rest_get_regression_status( \WP_REST_Request $request ): \WP_REST_Response { $post_id = (int) $request->get_param( 'id' ); - $post = \get_post( $post_id ); + $post = \get_post( $post_id ); if ( ! $post ) { return new \WP_REST_Response( array( 'error' => 'Post not found' ), 404 ); @@ -1011,8 +1040,8 @@ public static function rest_get_regression_status( \WP_REST_Request $request ) : return new \WP_REST_Response( array( 'error' => 'Permission denied' ), 403 ); } - $warnings = self::detect_regressions( $post_id ); - $baseline = self::get_last_stable_snapshot( $post_id ); + $warnings = self::detect_regressions( $post_id ); + $baseline = self::get_last_stable_snapshot( $post_id ); $post_settings = self::get_post_settings( $post_id ); // Also capture current snapshot for comparison display. @@ -1021,13 +1050,16 @@ public static function rest_get_regression_status( \WP_REST_Request $request ) : $current = self::capture_snapshot( $post ); } - return new \WP_REST_Response( array( - 'warnings' => $warnings, - 'baseline' => $baseline, - 'current' => $current, - 'post_settings' => $post_settings, - 'has_baseline' => ! empty( $baseline ), - ) ); + return new \WP_REST_Response( + array( + 'warnings' => $warnings, + 'baseline' => $baseline, + 'current' => $current, + 'diff' => self::build_snapshot_diff( $baseline, $current ), + 'post_settings' => $post_settings, + 'has_baseline' => ! empty( $baseline ), + ) + ); } /** @@ -1036,9 +1068,9 @@ public static function rest_get_regression_status( \WP_REST_Request $request ) : * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response. */ - public static function rest_mark_intentional( \WP_REST_Request $request ) : \WP_REST_Response { + public static function rest_mark_intentional( \WP_REST_Request $request ): \WP_REST_Response { $post_id = (int) $request->get_param( 'id' ); - $post = \get_post( $post_id ); + $post = \get_post( $post_id ); if ( ! $post ) { return new \WP_REST_Response( array( 'error' => 'Post not found' ), 404 ); @@ -1049,10 +1081,11 @@ public static function rest_mark_intentional( \WP_REST_Request $request ) : \WP_ } // Mark current snapshot as stable (intentional change). - $opts = self::get_options(); - $snapshot = self::capture_snapshot( $post ); + $opts = self::get_options(); + $snapshot = self::capture_snapshot( $post ); $snapshot['is_stable_version'] = true; self::store_snapshot( $post_id, $snapshot, $opts ); + self::record_audit( $post_id, 'mark_intentional' ); return new \WP_REST_Response( array( 'success' => true ) ); } @@ -1063,9 +1096,9 @@ public static function rest_mark_intentional( \WP_REST_Request $request ) : \WP_ * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response. */ - public static function rest_reset_baseline( \WP_REST_Request $request ) : \WP_REST_Response { + public static function rest_reset_baseline( \WP_REST_Request $request ): \WP_REST_Response { $post_id = (int) $request->get_param( 'id' ); - $post = \get_post( $post_id ); + $post = \get_post( $post_id ); if ( ! $post ) { return new \WP_REST_Response( array( 'error' => 'Post not found' ), 404 ); @@ -1079,9 +1112,10 @@ public static function rest_reset_baseline( \WP_REST_Request $request ) : \WP_RE \delete_post_meta( $post_id, self::META_KEY ); // Capture new baseline. - $opts = self::get_options(); + $opts = self::get_options(); $snapshot = self::capture_snapshot( $post ); self::store_snapshot( $post_id, $snapshot, $opts ); + self::record_audit( $post_id, 'reset_baseline' ); return new \WP_REST_Response( array( 'success' => true ) ); } @@ -1092,9 +1126,9 @@ public static function rest_reset_baseline( \WP_REST_Request $request ) : \WP_RE * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response Response. */ - public static function rest_update_post_settings( \WP_REST_Request $request ) : \WP_REST_Response { + public static function rest_update_post_settings( \WP_REST_Request $request ): \WP_REST_Response { $post_id = (int) $request->get_param( 'id' ); - $post = \get_post( $post_id ); + $post = \get_post( $post_id ); if ( ! $post ) { return new \WP_REST_Response( array( 'error' => 'Post not found' ), 404 ); @@ -1104,8 +1138,8 @@ public static function rest_update_post_settings( \WP_REST_Request $request ) : return new \WP_REST_Response( array( 'error' => 'Permission denied' ), 403 ); } - $params = $request->get_json_params(); - $allowed = array( 'detection_disabled', 'is_short_form' ); + $params = $request->get_json_params(); + $allowed = array( 'detection_disabled', 'is_short_form' ); $settings = array(); foreach ( $allowed as $key ) { @@ -1118,10 +1152,12 @@ public static function rest_update_post_settings( \WP_REST_Request $request ) : self::update_post_settings( $post_id, $settings ); } - return new \WP_REST_Response( array( - 'success' => true, - 'settings' => self::get_post_settings( $post_id ), - ) ); + return new \WP_REST_Response( + array( + 'success' => true, + 'settings' => self::get_post_settings( $post_id ), + ) + ); } /** @@ -1129,7 +1165,7 @@ public static function rest_update_post_settings( \WP_REST_Request $request ) : * * @return void */ - public static function setup_post_columns() : void { + public static function setup_post_columns(): void { $opts = self::get_options(); if ( empty( $opts['show_post_column'] ) ) { @@ -1160,7 +1196,7 @@ public static function setup_post_columns() : void { * @param array $columns Existing columns. * @return array Modified columns. */ - public static function add_column( array $columns ) : array { + public static function add_column( array $columns ): array { $columns['functionalities_regression'] = ''; return $columns; } @@ -1172,7 +1208,7 @@ public static function add_column( array $columns ) : array { * @param int $post_id Post ID. * @return void */ - public static function render_column( string $column, int $post_id ) : void { + public static function render_column( string $column, int $post_id ): void { if ( 'functionalities_regression' !== $column ) { return; } @@ -1197,7 +1233,7 @@ public static function render_column( string $column, int $post_id ) : void { * * @return void */ - public static function enqueue_editor_assets() : void { + public static function enqueue_editor_assets(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -1223,24 +1259,30 @@ public static function enqueue_editor_assets() : void { true ); - \wp_localize_script( 'functionalities-content-regression', 'functionalitiesRegressionData', array( - 'postId' => $post->ID, - 'restBase' => '/functionalities/v1/regression/', - 'nonce' => \wp_create_nonce( 'wp_rest' ), - 'i18n' => array( - 'panelTitle' => \__( 'Content Integrity', 'functionalities' ), - 'noIssues' => \__( 'No structural issues detected.', 'functionalities' ), - 'noBaseline' => \__( 'No baseline snapshot available yet. Changes will be tracked after first publish.', 'functionalities' ), - 'reviewChanges' => \__( 'Review changes', 'functionalities' ), - 'ignoreThisUpdate' => \__( 'Ignore for this update', 'functionalities' ), - 'markIntentional' => \__( 'Mark change as intentional', 'functionalities' ), - 'resetBaseline' => \__( 'Reset baseline', 'functionalities' ), - 'disableDetection' => \__( 'Disable detection for this post', 'functionalities' ), - 'markAsShortForm' => \__( 'Mark as short-form content', 'functionalities' ), - 'lastSnapshot' => \__( 'Last snapshot:', 'functionalities' ), - 'loading' => \__( 'Checking content integrity...', 'functionalities' ), - ), - ) ); + \wp_localize_script( + 'functionalities-content-regression', + 'functionalitiesRegressionData', + array( + 'postId' => $post->ID, + 'restBase' => '/functionalities/v1/regression/', + 'nonce' => \wp_create_nonce( 'wp_rest' ), + 'i18n' => array( + 'panelTitle' => \__( 'Content Integrity', 'functionalities' ), + 'noIssues' => \__( 'No structural issues detected.', 'functionalities' ), + 'noBaseline' => \__( 'No baseline snapshot available yet. Changes will be tracked after first publish.', 'functionalities' ), + 'reviewChanges' => \__( 'Review changes', 'functionalities' ), + 'ignoreThisUpdate' => \__( 'Ignore for this update', 'functionalities' ), + 'markIntentional' => \__( 'Mark change as intentional', 'functionalities' ), + 'resetBaseline' => \__( 'Reset baseline', 'functionalities' ), + 'disableDetection' => \__( 'Disable detection for this post', 'functionalities' ), + 'markAsShortForm' => \__( 'Mark as short-form content', 'functionalities' ), + 'lastSnapshot' => \__( 'Last snapshot:', 'functionalities' ), + 'loading' => \__( 'Checking content integrity...', 'functionalities' ), + 'headingsAdded' => \__( 'Headings added:', 'functionalities' ), + 'headingsRemoved' => \__( 'Headings removed:', 'functionalities' ), + ), + ) + ); // CSS loaded via enqueue_editor_styles() on enqueue_block_assets for WP 7 iframe compatibility. } @@ -1251,7 +1293,7 @@ public static function enqueue_editor_assets() : void { * @since 1.3.0 * @return void */ - public static function enqueue_editor_styles() : void { + public static function enqueue_editor_styles(): void { if ( ! \is_admin() ) { return; } @@ -1284,7 +1326,7 @@ public static function enqueue_editor_styles() : void { * * @return void */ - public static function ajax_mark_intentional() : void { + public static function ajax_mark_intentional(): void { \check_ajax_referer( 'functionalities_regression', 'nonce' ); if ( ! \current_user_can( 'edit_posts' ) ) { @@ -1292,16 +1334,17 @@ public static function ajax_mark_intentional() : void { } $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0; - $post = \get_post( $post_id ); + $post = \get_post( $post_id ); if ( ! $post || ! \current_user_can( 'edit_post', $post_id ) ) { \wp_send_json_error( array( 'message' => \__( 'Invalid post.', 'functionalities' ) ) ); } - $opts = self::get_options(); - $snapshot = self::capture_snapshot( $post ); + $opts = self::get_options(); + $snapshot = self::capture_snapshot( $post ); $snapshot['is_stable_version'] = true; self::store_snapshot( $post_id, $snapshot, $opts ); + self::record_audit( $post_id, 'mark_intentional' ); \wp_send_json_success( array( 'message' => \__( 'Change marked as intentional.', 'functionalities' ) ) ); } @@ -1311,7 +1354,7 @@ public static function ajax_mark_intentional() : void { * * @return void */ - public static function ajax_reset_baseline() : void { + public static function ajax_reset_baseline(): void { \check_ajax_referer( 'functionalities_regression', 'nonce' ); if ( ! \current_user_can( 'edit_posts' ) ) { @@ -1319,7 +1362,7 @@ public static function ajax_reset_baseline() : void { } $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0; - $post = \get_post( $post_id ); + $post = \get_post( $post_id ); if ( ! $post || ! \current_user_can( 'edit_post', $post_id ) ) { \wp_send_json_error( array( 'message' => \__( 'Invalid post.', 'functionalities' ) ) ); @@ -1327,9 +1370,10 @@ public static function ajax_reset_baseline() : void { \delete_post_meta( $post_id, self::META_KEY ); - $opts = self::get_options(); + $opts = self::get_options(); $snapshot = self::capture_snapshot( $post ); self::store_snapshot( $post_id, $snapshot, $opts ); + self::record_audit( $post_id, 'reset_baseline' ); \wp_send_json_success( array( 'message' => \__( 'Baseline has been reset.', 'functionalities' ) ) ); } @@ -1339,7 +1383,7 @@ public static function ajax_reset_baseline() : void { * * @return void */ - public static function ajax_get_regression_status() : void { + public static function ajax_get_regression_status(): void { \check_ajax_referer( 'functionalities_regression', 'nonce' ); if ( ! \current_user_can( 'edit_posts' ) ) { @@ -1347,25 +1391,78 @@ public static function ajax_get_regression_status() : void { } $post_id = isset( $_POST['post_id'] ) ? (int) $_POST['post_id'] : 0; - $post = \get_post( $post_id ); + $post = \get_post( $post_id ); if ( ! $post || ! \current_user_can( 'edit_post', $post_id ) ) { \wp_send_json_error( array( 'message' => \__( 'Invalid post.', 'functionalities' ) ) ); } - $warnings = self::detect_regressions( $post_id ); - $baseline = self::get_last_stable_snapshot( $post_id ); + $warnings = self::detect_regressions( $post_id ); + $baseline = self::get_last_stable_snapshot( $post_id ); $post_settings = self::get_post_settings( $post_id ); // Also capture current snapshot for comparison display. $current = self::capture_snapshot( $post ); - \wp_send_json_success( array( - 'warnings' => $warnings, - 'baseline' => $baseline, - 'current' => $current, - 'post_settings' => $post_settings, - 'has_baseline' => ! empty( $baseline ), - ) ); + \wp_send_json_success( + array( + 'warnings' => $warnings, + 'baseline' => $baseline, + 'current' => $current, + 'diff' => self::build_snapshot_diff( $baseline, $current ), + 'post_settings' => $post_settings, + 'has_baseline' => ! empty( $baseline ), + ) + ); + } + + /** + * Build a human-readable structural difference between two snapshots. + * + * @param array|null $baseline Stable snapshot. + * @param array|null $current Current snapshot. + * @return array + */ + public static function build_snapshot_diff( ?array $baseline, ?array $current ): array { + if ( ! $baseline || ! $current ) { + return array(); + } + $metrics = array(); + foreach ( array( 'internal_link_count', 'external_link_count', 'word_count', 'h1_count' ) as $key ) { + $before = (int) ( $baseline[ $key ] ?? 0 ); + $after = (int) ( $current[ $key ] ?? 0 ); + $metrics[ $key ] = array( + 'before' => $before, + 'after' => $after, + 'delta' => $after - $before, + ); + } + $before_headings = array_map( 'wp_json_encode', (array) ( $baseline['heading_map'] ?? array() ) ); + $after_headings = array_map( 'wp_json_encode', (array) ( $current['heading_map'] ?? array() ) ); + + return array( + 'metrics' => $metrics, + 'headings_added' => array_values( array_map( 'json_decode', array_diff( $after_headings, $before_headings ) ) ), + 'headings_removed' => array_values( array_map( 'json_decode', array_diff( $before_headings, $after_headings ) ) ), + 'baseline_time' => (int) ( $baseline['timestamp'] ?? 0 ), + 'current_time' => (int) ( $current['timestamp'] ?? 0 ), + ); + } + + /** + * Append bounded audit metadata without storing post content. + * + * @param int $post_id Post ID. + * @param string $action Audit action. + * @return void + */ + private static function record_audit( int $post_id, string $action ): void { + $audit = (array) \get_post_meta( $post_id, '_functionalities_content_audit', true ); + $audit[] = array( + 'action' => $action, + 'user_id' => \get_current_user_id(), + 'timestamp' => time(), + ); + \update_post_meta( $post_id, '_functionalities_content_audit', array_slice( $audit, -50 ) ); } } diff --git a/includes/features/class-editor-links.php b/includes/features/class-editor-links.php index 70d6438..c1e3a6f 100644 --- a/includes/features/class-editor-links.php +++ b/includes/features/class-editor-links.php @@ -72,7 +72,7 @@ class Editor_Links { * * @return void */ - public static function init() : void { + public static function init(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -106,17 +106,17 @@ public static function init() : void { * * @return array Options array. */ - protected static function get_options() : array { + protected static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( + $defaults = array( 'enabled' => false, 'enable_limit' => false, 'post_types' => array(), ); - $opts = (array) \get_option( 'functionalities_editor_links', $defaults ); + $opts = (array) \get_option( 'functionalities_editor_links', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -131,7 +131,7 @@ protected static function get_options() : array { * * @return array Array of allowed post type slugs. */ - protected static function get_allowed_post_types() : array { + protected static function get_allowed_post_types(): array { $opts = self::get_options(); $allowed = (array) ( $opts['post_types'] ?? array() ); @@ -157,7 +157,7 @@ protected static function get_allowed_post_types() : array { * @param array $query WP_Query arguments for link search. * @return array Modified query arguments. */ - public static function filter_wp_link_query_args( array $query ) : array { + public static function filter_wp_link_query_args( array $query ): array { /** * Filters whether editor link filtering is enabled. * @@ -191,7 +191,7 @@ public static function filter_wp_link_query_args( array $query ) : array { * @param \WP_REST_Request $request The REST request object. * @return array Modified query arguments. */ - public static function filter_rest_search_query( array $prepared_args, $request ) : array { + public static function filter_rest_search_query( array $prepared_args, $request ): array { /** This filter is documented in class-editor-links.php */ if ( ! \apply_filters( 'functionalities_editor_links_enabled', true ) ) { return $prepared_args; @@ -227,7 +227,7 @@ public static function filter_rest_search_query( array $prepared_args, $request * @param \WP_REST_Request $request The REST request object. * @return array Modified query arguments. */ - public static function filter_rest_post_query( array $args, $request ) : array { + public static function filter_rest_post_query( array $args, $request ): array { /** This filter is documented in class-editor-links.php */ if ( ! \apply_filters( 'functionalities_editor_links_enabled', true ) ) { return $args; diff --git a/includes/features/class-fonts.php b/includes/features/class-fonts.php index 2ff5105..8705441 100644 --- a/includes/features/class-fonts.php +++ b/includes/features/class-fonts.php @@ -20,6 +20,7 @@ * Fonts class for custom @font-face rule generation. * * Provides a user interface for configuring custom fonts with proper + * * @font-face declarations. Supports both static and variable fonts, * WOFF2 and WOFF formats, and various font-display strategies. * @@ -122,7 +123,7 @@ class Fonts { * * @return void */ - public static function init() : void { + public static function init(): void { // Preload fonts early. \add_action( 'wp_head', array( __CLASS__, 'preload_fonts' ), 1 ); @@ -158,7 +159,7 @@ public static function init() : void { * * @return void */ - public static function flush_options_cache() : void { + public static function flush_options_cache(): void { self::$options = null; } @@ -196,12 +197,12 @@ public static function flush_options_cache() : void { * * @return array Options array. */ - protected static function get_options() : array { + protected static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( + $defaults = array( 'enabled' => false, 'items' => array(), 'assign_enabled' => false, @@ -210,7 +211,7 @@ protected static function get_options() : array { 'per_heading' => false, 'heading_fonts' => array(), ); - $opts = (array) \get_option( 'functionalities_fonts', $defaults ); + $opts = (array) \get_option( 'functionalities_fonts', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -219,6 +220,7 @@ protected static function get_options() : array { * Output @font-face CSS rules. * * Generates and outputs inline CSS containing all configured + * * @font-face declarations. * * @since 0.3.0 @@ -226,7 +228,7 @@ protected static function get_options() : array { * * @return void */ - public static function print_fonts_css() : void { + public static function print_fonts_css(): void { $opts = self::get_options(); /** @@ -277,7 +279,7 @@ public static function print_fonts_css() : void { * @since 0.13.0 * @return void */ - public static function preload_fonts() : void { + public static function preload_fonts(): void { if ( \is_admin() ) { return; } @@ -305,6 +307,7 @@ public static function preload_fonts() : void { * Inject custom @font-face CSS into the block editor's style settings. * * The editor canvas is iframed (WP 6.3+/7.x). The reliable way to get custom + * * @font-face rules into that iframe is the editor `styles` setting — the same * channel the Font Library and add_editor_style() feed, which WordPress copies * verbatim into the iframe document. This replaces two weaker approaches: @@ -385,7 +388,7 @@ public static function add_editor_settings_fonts( $settings ) { * @param array $opts Fonts options. * @return string Assignment CSS, or '' when nothing to assign. */ - protected static function build_assignment_css( array $opts ) : string { + protected static function build_assignment_css( array $opts ): string { if ( empty( $opts['assign_enabled'] ) ) { return ''; } @@ -425,12 +428,13 @@ protected static function build_assignment_css( array $opts ) : string { * * Injects font families into Bricks' Custom_Fonts cache so they appear * in the builder's font picker under "Custom Fonts" and generate + * * @font-face rules that Bricks loads automatically. * * @since 1.4.0 * @return void */ - public static function bricks_register_fonts() : void { + public static function bricks_register_fonts(): void { if ( ! defined( 'BRICKS_VERSION' ) || ! class_exists( '\Bricks\Custom_Fonts' ) ) { return; } @@ -484,7 +488,7 @@ public static function bricks_register_fonts() : void { * @since 1.4.0 * @return void */ - public static function bricks_enqueue_fonts() : void { + public static function bricks_enqueue_fonts(): void { // Only run inside Bricks builder context. if ( ! defined( 'BRICKS_VERSION' ) ) { return; @@ -575,10 +579,10 @@ public static function inject_typography_theme_json( $theme_json ) { } $face = array( - 'fontFamily' => $family, - 'fontStyle' => trim( (string) ( $item['style'] ?? 'normal' ) ), + 'fontFamily' => $family, + 'fontStyle' => trim( (string) ( $item['style'] ?? 'normal' ) ), 'fontDisplay' => trim( (string) ( $item['display'] ?? 'swap' ) ), - 'src' => array( $woff2 ), + 'src' => array( $woff2 ), ); $woff = trim( (string) ( $item['woff_url'] ?? '' ) ); @@ -630,7 +634,7 @@ public static function inject_typography_theme_json( $theme_json ) { $styles = array(); if ( $body_font !== '' ) { - $body_slug = sanitize_title( $body_font ); + $body_slug = sanitize_title( $body_font ); $styles['typography'] = array( 'fontFamily' => 'var(--wp--preset--font-family--' . $body_slug . ')', ); @@ -640,7 +644,7 @@ public static function inject_typography_theme_json( $theme_json ) { $elements = array(); if ( $heading_font !== '' ) { - $heading_slug = sanitize_title( $heading_font ); + $heading_slug = sanitize_title( $heading_font ); $elements['heading'] = array( 'typography' => array( 'fontFamily' => 'var(--wp--preset--font-family--' . $heading_slug . ')', @@ -654,7 +658,7 @@ public static function inject_typography_theme_json( $theme_json ) { $key = 'h' . $i; $font = trim( (string) ( $heading_fonts[ $key ] ?? '' ) ); if ( $font !== '' ) { - $font_slug = sanitize_title( $font ); + $font_slug = sanitize_title( $font ); $elements[ $key ] = array( 'typography' => array( 'fontFamily' => 'var(--wp--preset--font-family--' . $font_slug . ')', @@ -697,7 +701,7 @@ public static function inject_typography_theme_json( $theme_json ) { * @param string $range A single weight ("400") or space-separated range ("1 900"). * @return string Normalized weight or range. */ - protected static function normalize_weight_range( string $range ) : string { + protected static function normalize_weight_range( string $range ): string { $range = trim( $range ); if ( $range === '' ) { return ''; @@ -717,7 +721,7 @@ protected static function normalize_weight_range( string $range ) : string { return implode( ' ', $normalized ); } - protected static function build_css( array $items ) : string { + protected static function build_css( array $items ): string { $parts = array(); foreach ( $items as $item ) { @@ -807,7 +811,7 @@ protected static function build_css( array $items ) : string { * @param array $mimes Associative array of allowed MIME types. * @return array Modified MIME types with font formats added. */ - public static function allow_font_mimes( array $mimes ) : array { + public static function allow_font_mimes( array $mimes ): array { if ( ! \current_user_can( 'upload_files' ) ) { return $mimes; } @@ -828,10 +832,10 @@ public static function allow_font_mimes( array $mimes ) : array { * * @since 1.4.5 * - * @param array $wp_check Array of file data (ext, type, proper_filename). - * @param string $file Full path to the file. - * @param string $filename The name of the file. - * @param string[] $mimes Allowed MIME types keyed by extension. + * @param array $wp_check Array of file data (ext, type, proper_filename). + * @param string $file Full path to the file. + * @param string $filename The name of the file. + * @param string[] $mimes Allowed MIME types keyed by extension. * @param string|false $real_mime The real MIME type or false. * @return array Modified file check data. */ diff --git a/includes/features/class-link-management.php b/includes/features/class-link-management.php index 6076e88..640e44d 100644 --- a/includes/features/class-link-management.php +++ b/includes/features/class-link-management.php @@ -29,7 +29,7 @@ class Link_Management { * * @return void */ - public static function init() : void { + public static function init(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -71,22 +71,22 @@ public static function init() : void { * * @return array Options array. */ - protected static function get_options() : array { + protected static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( - 'enabled' => false, - 'nofollow_external' => false, - 'exceptions' => '', - 'open_external_new_tab' => false, - 'open_internal_new_tab' => false, - 'internal_new_tab_exceptions' => '', - 'json_preset_url' => '', - 'enable_developer_filters' => false, + $defaults = array( + 'enabled' => false, + 'nofollow_external' => false, + 'exceptions' => '', + 'open_external_new_tab' => false, + 'open_internal_new_tab' => false, + 'internal_new_tab_exceptions' => '', + 'json_preset_url' => '', + 'enable_developer_filters' => false, ); - $opts = (array) \get_option( 'functionalities_link_management', $defaults ); + $opts = (array) \get_option( 'functionalities_link_management', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -103,7 +103,7 @@ protected static function get_options() : array { * * @return void */ - public static function load_json_preset() : void { + public static function load_json_preset(): void { $opts = self::get_options(); $json_path = ''; @@ -181,7 +181,7 @@ public static function load_json_preset() : void { * @param string $source The file path or URL to check. * @return bool True if valid source. */ - private static function is_valid_json_source( string $source ) : bool { + private static function is_valid_json_source( string $source ): bool { // Check if it's a URL. if ( filter_var( $source, FILTER_VALIDATE_URL ) ) { return true; @@ -225,6 +225,7 @@ private static function get_json_content( string $source ) { return false; } + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local, administrator-configured file path. return file_get_contents( $source ); } @@ -238,7 +239,7 @@ private static function get_json_content( string $source ) { * @param string $content The content to filter. * @return string Filtered content. */ - public static function filter_content( string $content ) : string { + public static function filter_content( string $content ): string { // Skip in admin, feeds, and REST requests. $is_rest = \defined( 'REST_REQUEST' ) && \constant( 'REST_REQUEST' ); if ( \is_admin() || \is_feed() || $is_rest ) { @@ -269,7 +270,7 @@ public static function filter_content( string $content ) : string { * @param string $content HTML content containing links to process. * @return string Processed content with nofollow/target attributes applied. */ - public static function process_content( string $content ) : string { + public static function process_content( string $content ): string { if ( trim( $content ) === '' || false === strpos( $content, ''; + $dom = new \DOMDocument( '1.0', 'UTF-8' ); + $html = '
    ' . $content . '
    '; $dom->loadHTML( '' . $html ); $xpath = new \DOMXPath( $dom ); $nodes = $xpath->query( '//a[@href]' ); if ( $nodes instanceof \DOMNodeList ) { foreach ( $nodes as $a ) { - $href = (string) $a->getAttribute( 'href' ); + $href = (string) $a->getAttribute( 'href' ); $is_external = self::is_external_url( $href, $site_host ); // Nofollow external if ( $is_external && ! self::is_exception( $href, $exceptions ) && ! empty( $opts['nofollow_external'] ) ) { - $rel = (string) $a->getAttribute( 'rel' ); + $rel = (string) $a->getAttribute( 'rel' ); $parts = preg_split( '/\s+/', strtolower( $rel ) ); $parts = array_filter( array_unique( array_map( 'trim', (array) $parts ) ) ); - if ( ! in_array( 'nofollow', $parts, true ) ) { $parts[] = 'nofollow'; } + if ( ! in_array( 'nofollow', $parts, true ) ) { + $parts[] = 'nofollow'; } $a->setAttribute( 'rel', implode( ' ', $parts ) ); } @@ -310,9 +312,10 @@ public static function process_content( string $content ) : string { if ( $is_external && ! empty( $opts['open_external_new_tab'] ) ) { $a->setAttribute( 'target', '_blank' ); // add noopener - $rel = strtolower( (string) $a->getAttribute( 'rel' ) ); + $rel = strtolower( (string) $a->getAttribute( 'rel' ) ); $parts = array_filter( array_unique( preg_split( '/\s+/', $rel ) ) ); - if ( ! in_array( 'noopener', $parts, true ) ) { $parts[] = 'noopener'; } + if ( ! in_array( 'noopener', $parts, true ) ) { + $parts[] = 'noopener'; } $a->setAttribute( 'rel', implode( ' ', $parts ) ); } @@ -320,14 +323,17 @@ public static function process_content( string $content ) : string { if ( ! $is_external && ! empty( $opts['open_internal_new_tab'] ) ) { // If link host matches an exception domain, skip $test = $href; - if ( strpos( $href, '//' ) === 0 ) { $test = 'http:' . $href; } + if ( strpos( $href, '//' ) === 0 ) { + $test = 'http:' . $href; } $host = (string) \wp_parse_url( $test, PHP_URL_HOST ); $host = strtolower( $host ); $skip = false; foreach ( $internal_ex as $exd ) { - if ( $exd === '' ) { continue; } + if ( $exd === '' ) { + continue; } if ( $host === $exd || ( $host !== '' && substr( $host, - ( strlen( $exd ) + 1 ) ) === '.' . $exd ) ) { - $skip = true; break; + $skip = true; + break; } } if ( ! $skip ) { @@ -337,7 +343,7 @@ public static function process_content( string $content ) : string { } } - $out = ''; + $out = ''; $wrapper = $dom->getElementById( '__functionalities_wrapper' ); if ( $wrapper ) { foreach ( $wrapper->childNodes as $child ) { @@ -363,7 +369,7 @@ public static function process_content( string $content ) : string { * @param string $raw Raw exception text (one per line or comma-separated). * @return array Array of exceptions. */ - protected static function parse_exceptions( string $raw ) : array { + protected static function parse_exceptions( string $raw ): array { $cache_key = md5( $raw ); if ( isset( self::$runtime_exceptions_cache[ $cache_key ] ) ) { return self::$runtime_exceptions_cache[ $cache_key ]; @@ -397,7 +403,7 @@ protected static function parse_exceptions( string $raw ) : array { * @param string $target_url The URL to add nofollow to. * @return array Results with success count and errors. */ - public static function update_links_in_database( string $target_url ) : array { + public static function update_links_in_database( string $target_url ): array { global $wpdb; $target_url = trim( $target_url ); @@ -444,7 +450,7 @@ public static function update_links_in_database( string $target_url ) : array { $processed = 0; foreach ( $posts as $post ) { - $processed++; + ++$processed; // Process links in content. $new_content = self::add_nofollow_to_url_in_content( $post->post_content, $target_url ); @@ -459,7 +465,7 @@ public static function update_links_in_database( string $target_url ) : array { array( '%d' ) ); \clean_post_cache( $post->ID ); - $updated_count++; + ++$updated_count; } // Prevent timeout on large batches. @@ -495,7 +501,7 @@ public static function update_links_in_database( string $target_url ) : array { * @param string $target_url URL to add nofollow to. * @return string Modified content. */ - protected static function add_nofollow_to_url_in_content( string $content, string $target_url ) : string { + protected static function add_nofollow_to_url_in_content( string $content, string $target_url ): string { // Early exit if no links. if ( false === strpos( $content, ']*)>/i'; - return preg_replace_callback( $pattern, function( $matches ) { - $tag = $matches[0]; - $attributes = $matches[1]; + return preg_replace_callback( + $pattern, + function ( $matches ) { + $tag = $matches[0]; + $attributes = $matches[1]; - // Check if already has nofollow. - if ( preg_match( '/rel=["\']([^"\']*nofollow[^"\']*)["\']/i', $attributes ) ) { - return $tag; - } + // Check if already has nofollow. + if ( preg_match( '/rel=["\']([^"\']*nofollow[^"\']*)["\']/i', $attributes ) ) { + return $tag; + } - // Add or append nofollow. - if ( preg_match( '/rel=["\']/i', $attributes ) ) { - return preg_replace( '/rel=["\']([^"\']*)["\']/i', 'rel="$1 nofollow"', $tag ); - } else { - return str_replace( ' false, - 'limit_login_attempts' => true, - 'max_attempts' => 5, - 'lockout_duration' => 15, // minutes - 'disable_xmlrpc_auth' => true, + $defaults = array( + 'enabled' => false, + 'limit_login_attempts' => true, + 'max_attempts' => 5, + 'lockout_duration' => 15, // minutes + 'disable_xmlrpc_auth' => true, 'disable_application_passwords' => false, - 'hide_login_errors' => true, - 'trust_proxy_headers' => false, - 'custom_logo_url' => '', - 'custom_background_color' => '', - 'custom_form_background' => '', + 'hide_login_errors' => true, + 'trust_proxy_headers' => false, + 'custom_logo_url' => '', + 'custom_background_color' => '', + 'custom_form_background' => '', ); - $opts = (array) \get_option( 'functionalities_login_security', $defaults ); + $opts = (array) \get_option( 'functionalities_login_security', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -134,7 +134,7 @@ public static function get_options() : array { * * @return string IP address (validated, or empty string on failure). */ - private static function get_client_ip() : string { + private static function get_client_ip(): string { $ip = ''; $opts = self::get_options(); @@ -175,7 +175,7 @@ public static function check_lockout( $user, $username, $password ) { return $user; } - $ip = self::get_client_ip(); + $ip = self::get_client_ip(); $lockout_key = self::LOCKOUT_PREFIX . md5( $ip ); if ( \get_transient( $lockout_key ) ) { @@ -199,15 +199,15 @@ public static function check_lockout( $user, $username, $password ) { * @param string $username Username that failed. * @return void */ - public static function record_failed_attempt( $username ) : void { - $opts = self::get_options(); - $ip = self::get_client_ip(); + public static function record_failed_attempt( $username ): void { + $opts = self::get_options(); + $ip = self::get_client_ip(); $attempts_key = self::ATTEMPTS_PREFIX . md5( $ip ); - $lockout_key = self::LOCKOUT_PREFIX . md5( $ip ); + $lockout_key = self::LOCKOUT_PREFIX . md5( $ip ); // Get current attempts. $attempts = (int) \get_transient( $attempts_key ); - $attempts++; + ++$attempts; // Store attempts for 1 hour. \set_transient( $attempts_key, $attempts, HOUR_IN_SECONDS ); @@ -233,8 +233,8 @@ public static function record_failed_attempt( $username ) : void { * @param \WP_User $user User object. * @return void */ - public static function clear_attempts( $username, $user ) : void { - $ip = self::get_client_ip(); + public static function clear_attempts( $username, $user ): void { + $ip = self::get_client_ip(); $attempts_key = self::ATTEMPTS_PREFIX . md5( $ip ); \delete_transient( $attempts_key ); } @@ -245,11 +245,11 @@ public static function clear_attempts( $username, $user ) : void { * @param string $error Original error. * @return string Modified error. */ - public static function custom_login_error( $error ) : string { - $opts = self::get_options(); - $ip = self::get_client_ip(); + public static function custom_login_error( $error ): string { + $opts = self::get_options(); + $ip = self::get_client_ip(); $attempts_key = self::ATTEMPTS_PREFIX . md5( $ip ); - $attempts = (int) \get_transient( $attempts_key ); + $attempts = (int) \get_transient( $attempts_key ); $max_attempts = isset( $opts['max_attempts'] ) ? (int) $opts['max_attempts'] : 5; $remaining = $max_attempts - $attempts; @@ -270,7 +270,7 @@ public static function custom_login_error( $error ) : string { * @param string $error Original error. * @return string Generic error. */ - public static function generic_login_error( $error ) : string { + public static function generic_login_error( $error ): string { // Check if it's a lockout message. if ( strpos( $error, 'Too many failed login attempts' ) !== false ) { return $error; @@ -292,7 +292,7 @@ public static function generic_login_error( $error ) : string { * @param int $attempts Number of attempts. * @return void */ - private static function log_lockout( $ip, $username, $attempts ) : void { + private static function log_lockout( $ip, $username, $attempts ): void { $logs = \get_option( 'functionalities_login_lockouts', array() ); $logs[] = array( @@ -314,7 +314,7 @@ private static function log_lockout( $ip, $username, $attempts ) : void { * @param int $limit Number of entries. * @return array Log entries. */ - public static function get_lockout_log( int $limit = 20 ) : array { + public static function get_lockout_log( int $limit = 20 ): array { $logs = \get_option( 'functionalities_login_lockouts', array() ); return array_slice( array_reverse( $logs ), 0, $limit ); } @@ -324,7 +324,7 @@ public static function get_lockout_log( int $limit = 20 ) : array { * * @return bool True on success. */ - public static function clear_lockout_log() : bool { + public static function clear_lockout_log(): bool { return \delete_option( 'functionalities_login_lockouts' ); } @@ -334,7 +334,7 @@ public static function clear_lockout_log() : bool { * @param array $methods XML-RPC methods. * @return array Filtered methods. */ - public static function disable_xmlrpc_methods( $methods ) : array { + public static function disable_xmlrpc_methods( $methods ): array { // Remove authentication-related methods. unset( $methods['wp.getUsersBlogs'] ); unset( $methods['wp.getCategories'] ); @@ -354,8 +354,8 @@ public static function disable_xmlrpc_methods( $methods ) : array { * * @return void */ - public static function custom_login_logo() : void { - $opts = self::get_options(); + public static function custom_login_logo(): void { + $opts = self::get_options(); $logo_url = esc_url( $opts['custom_logo_url'] ); if ( empty( $logo_url ) ) { return; @@ -379,10 +379,10 @@ public static function custom_login_logo() : void { * * @return void */ - public static function custom_login_styles() : void { - $opts = self::get_options(); + public static function custom_login_styles(): void { + $opts = self::get_options(); $bg_color = sanitize_hex_color( $opts['custom_background_color'] ?? '' ); - $form_bg = sanitize_hex_color( $opts['custom_form_background'] ?? '' ); + $form_bg = sanitize_hex_color( $opts['custom_form_background'] ?? '' ); if ( empty( $bg_color ) && empty( $form_bg ) ) { return; @@ -408,7 +408,7 @@ public static function custom_login_styles() : void { * * @return string Home URL. */ - public static function login_logo_url() : string { + public static function login_logo_url(): string { return \home_url(); } @@ -417,7 +417,7 @@ public static function login_logo_url() : string { * * @return string Site name. */ - public static function login_logo_title() : string { + public static function login_logo_title(): string { return \get_bloginfo( 'name' ); } @@ -427,7 +427,7 @@ public static function login_logo_title() : string { * @param string $ip IP address (optional, uses current if not provided). * @return bool True if locked out. */ - public static function is_locked_out( string $ip = '' ) : bool { + public static function is_locked_out( string $ip = '' ): bool { if ( empty( $ip ) ) { $ip = self::get_client_ip(); } @@ -441,7 +441,7 @@ public static function is_locked_out( string $ip = '' ) : bool { * @param string $ip IP address (optional). * @return int Current attempts. */ - public static function get_attempts( string $ip = '' ) : int { + public static function get_attempts( string $ip = '' ): int { if ( empty( $ip ) ) { $ip = self::get_client_ip(); } @@ -455,9 +455,9 @@ public static function get_attempts( string $ip = '' ) : int { * @param string $ip IP address. * @return bool True on success. */ - public static function unlock_ip( string $ip ) : bool { + public static function unlock_ip( string $ip ): bool { $attempts_key = self::ATTEMPTS_PREFIX . md5( $ip ); - $lockout_key = self::LOCKOUT_PREFIX . md5( $ip ); + $lockout_key = self::LOCKOUT_PREFIX . md5( $ip ); \delete_transient( $attempts_key ); \delete_transient( $lockout_key ); diff --git a/includes/features/class-meta.php b/includes/features/class-meta.php index 2a04115..656e318 100644 --- a/includes/features/class-meta.php +++ b/includes/features/class-meta.php @@ -54,7 +54,7 @@ class Meta { * * @return void */ - public static function init() : void { + public static function init(): void { $options = self::get_options(); // Only initialize if module is enabled. @@ -87,67 +87,70 @@ public static function init() : void { * * @return void */ - private static function init_licenses() : void { + private static function init_licenses(): void { $options = self::get_options(); $site_name = \get_bloginfo( 'name' ); $home_url = \home_url( '/' ); // Allow customization via filter. - self::$licenses = \apply_filters( 'functionalities_meta_licenses', array( - 'all-rights-reserved' => array( - 'rights' => \__( 'All Rights Reserved', 'functionalities' ), - 'dc_rights' => sprintf( - /* translators: %1$s: year, %2$s: site name */ - \__( '© %1$s %2$s. All rights reserved. No reproduction without permission.', 'functionalities' ), - gmdate( 'Y' ), - $site_name + self::$licenses = \apply_filters( + 'functionalities_meta_licenses', + array( + 'all-rights-reserved' => array( + 'rights' => \__( 'All Rights Reserved', 'functionalities' ), + 'dc_rights' => sprintf( + /* translators: %1$s: year, %2$s: site name */ + \__( '© %1$s %2$s. All rights reserved. No reproduction without permission.', 'functionalities' ), + gmdate( 'Y' ), + $site_name + ), + 'url' => ! empty( $options['default_license_url'] ) ? $options['default_license_url'] : $home_url . 'disclaimer/', + 'name' => \__( 'All Rights Reserved', 'functionalities' ), ), - 'url' => ! empty( $options['default_license_url'] ) ? $options['default_license_url'] : $home_url . 'disclaimer/', - 'name' => \__( 'All Rights Reserved', 'functionalities' ), - ), - 'cc-by' => array( - 'rights' => \__( 'Creative Commons Attribution 4.0', 'functionalities' ), - 'dc_rights' => \__( 'This work is licensed under CC BY 4.0', 'functionalities' ), - 'url' => 'https://creativecommons.org/licenses/by/4.0/', - 'name' => 'CC BY 4.0', - ), - 'cc-by-sa' => array( - 'rights' => \__( 'Creative Commons Attribution-ShareAlike 4.0', 'functionalities' ), - 'dc_rights' => \__( 'This work is licensed under CC BY-SA 4.0', 'functionalities' ), - 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/', - 'name' => 'CC BY-SA 4.0', - ), - 'cc-by-nc' => array( - 'rights' => \__( 'Creative Commons Attribution-NonCommercial 4.0', 'functionalities' ), - 'dc_rights' => \__( 'This work is licensed under CC BY-NC 4.0', 'functionalities' ), - 'url' => 'https://creativecommons.org/licenses/by-nc/4.0/', - 'name' => 'CC BY-NC 4.0', - ), - 'cc-by-nc-sa' => array( - 'rights' => \__( 'Creative Commons Attribution-NonCommercial-ShareAlike 4.0', 'functionalities' ), - 'dc_rights' => \__( 'This work is licensed under CC BY-NC-SA 4.0', 'functionalities' ), - 'url' => 'https://creativecommons.org/licenses/by-nc-sa/4.0/', - 'name' => 'CC BY-NC-SA 4.0', - ), - 'cc-by-nd' => array( - 'rights' => \__( 'Creative Commons Attribution-NoDerivatives 4.0', 'functionalities' ), - 'dc_rights' => \__( 'This work is licensed under CC BY-ND 4.0', 'functionalities' ), - 'url' => 'https://creativecommons.org/licenses/by-nd/4.0/', - 'name' => 'CC BY-ND 4.0', - ), - 'cc-by-nc-nd' => array( - 'rights' => \__( 'Creative Commons Attribution-NonCommercial-NoDerivatives 4.0', 'functionalities' ), - 'dc_rights' => \__( 'This work is licensed under CC BY-NC-ND 4.0', 'functionalities' ), - 'url' => 'https://creativecommons.org/licenses/by-nc-nd/4.0/', - 'name' => 'CC BY-NC-ND 4.0', - ), - 'cc0' => array( - 'rights' => \__( 'CC0 1.0 Universal (Public Domain)', 'functionalities' ), - 'dc_rights' => \__( 'This work is dedicated to the public domain under CC0 1.0', 'functionalities' ), - 'url' => 'https://creativecommons.org/publicdomain/zero/1.0/', - 'name' => 'CC0 1.0', - ), - ) ); + 'cc-by' => array( + 'rights' => \__( 'Creative Commons Attribution 4.0', 'functionalities' ), + 'dc_rights' => \__( 'This work is licensed under CC BY 4.0', 'functionalities' ), + 'url' => 'https://creativecommons.org/licenses/by/4.0/', + 'name' => 'CC BY 4.0', + ), + 'cc-by-sa' => array( + 'rights' => \__( 'Creative Commons Attribution-ShareAlike 4.0', 'functionalities' ), + 'dc_rights' => \__( 'This work is licensed under CC BY-SA 4.0', 'functionalities' ), + 'url' => 'https://creativecommons.org/licenses/by-sa/4.0/', + 'name' => 'CC BY-SA 4.0', + ), + 'cc-by-nc' => array( + 'rights' => \__( 'Creative Commons Attribution-NonCommercial 4.0', 'functionalities' ), + 'dc_rights' => \__( 'This work is licensed under CC BY-NC 4.0', 'functionalities' ), + 'url' => 'https://creativecommons.org/licenses/by-nc/4.0/', + 'name' => 'CC BY-NC 4.0', + ), + 'cc-by-nc-sa' => array( + 'rights' => \__( 'Creative Commons Attribution-NonCommercial-ShareAlike 4.0', 'functionalities' ), + 'dc_rights' => \__( 'This work is licensed under CC BY-NC-SA 4.0', 'functionalities' ), + 'url' => 'https://creativecommons.org/licenses/by-nc-sa/4.0/', + 'name' => 'CC BY-NC-SA 4.0', + ), + 'cc-by-nd' => array( + 'rights' => \__( 'Creative Commons Attribution-NoDerivatives 4.0', 'functionalities' ), + 'dc_rights' => \__( 'This work is licensed under CC BY-ND 4.0', 'functionalities' ), + 'url' => 'https://creativecommons.org/licenses/by-nd/4.0/', + 'name' => 'CC BY-ND 4.0', + ), + 'cc-by-nc-nd' => array( + 'rights' => \__( 'Creative Commons Attribution-NonCommercial-NoDerivatives 4.0', 'functionalities' ), + 'dc_rights' => \__( 'This work is licensed under CC BY-NC-ND 4.0', 'functionalities' ), + 'url' => 'https://creativecommons.org/licenses/by-nc-nd/4.0/', + 'name' => 'CC BY-NC-ND 4.0', + ), + 'cc0' => array( + 'rights' => \__( 'CC0 1.0 Universal (Public Domain)', 'functionalities' ), + 'dc_rights' => \__( 'This work is dedicated to the public domain under CC0 1.0', 'functionalities' ), + 'url' => 'https://creativecommons.org/publicdomain/zero/1.0/', + 'name' => 'CC0 1.0', + ), + ) + ); } /** @@ -157,7 +160,7 @@ private static function init_licenses() : void { * * @return string Plugin identifier: 'rank-math', 'yoast', 'seo-framework', 'seopress', 'aioseo', or 'none'. */ - public static function detect_seo_plugin() : string { + public static function detect_seo_plugin(): string { // Return cached result if available. if ( self::$detected_seo_plugin !== null ) { return self::$detected_seo_plugin; @@ -186,7 +189,7 @@ public static function detect_seo_plugin() : string { * * @return void */ - private static function init_seo_integrations() : void { + private static function init_seo_integrations(): void { $seo_plugin = self::detect_seo_plugin(); switch ( $seo_plugin ) { @@ -227,7 +230,7 @@ private static function init_seo_integrations() : void { * * @return void */ - public static function output_standalone_schema() : void { + public static function output_standalone_schema(): void { $options = self::get_options(); // Check if we should output on this post type. @@ -249,19 +252,19 @@ public static function output_standalone_schema() : void { // Build the schema. $schema = array( - '@context' => 'https://schema.org', - '@type' => 'Article', - 'headline' => \get_the_title(), - 'datePublished' => \get_the_date( 'c' ), - 'dateModified' => \get_the_modified_date( 'c' ), - 'copyrightYear' => $copyright['copyrightYear'], - 'copyrightHolder' => $copyright['copyrightHolder'], - 'author' => array( + '@context' => 'https://schema.org', + '@type' => 'Article', + 'headline' => \get_the_title(), + 'datePublished' => \get_the_date( 'c' ), + 'dateModified' => \get_the_modified_date( 'c' ), + 'copyrightYear' => $copyright['copyrightYear'], + 'copyrightHolder' => $copyright['copyrightHolder'], + 'author' => array( '@type' => $holder['type'], 'name' => $holder['name'], 'url' => $holder['url'], ), - 'publisher' => array( + 'publisher' => array( '@type' => 'Organization', 'name' => \get_bloginfo( 'name' ), 'url' => \home_url( '/' ), @@ -309,25 +312,25 @@ public static function output_standalone_schema() : void { * * @return array Module options. */ - public static function get_options() : array { + public static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( - 'enabled' => false, - 'enable_copyright_meta' => true, - 'enable_dublin_core' => true, - 'enable_license_metabox' => true, + $defaults = array( + 'enabled' => false, + 'enable_copyright_meta' => true, + 'enable_dublin_core' => true, + 'enable_license_metabox' => true, 'enable_schema_integration' => true, - 'default_license' => 'all-rights-reserved', - 'default_license_url' => '', - 'post_types' => array( 'post' ), - 'copyright_holder_type' => 'author', // 'author', 'site', 'custom'. - 'custom_copyright_holder' => '', - 'dc_language' => '', + 'default_license' => 'all-rights-reserved', + 'default_license_url' => '', + 'post_types' => array( 'post' ), + 'copyright_holder_type' => 'author', // 'author', 'site', 'custom'. + 'custom_copyright_holder' => '', + 'dc_language' => '', ); - $opts = (array) \get_option( 'functionalities_meta', $defaults ); + $opts = (array) \get_option( 'functionalities_meta', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -338,7 +341,7 @@ public static function get_options() : array { * @param string $license License identifier. * @return array License data. */ - public static function get_license_data( string $license ) : array { + public static function get_license_data( string $license ): array { if ( empty( self::$licenses ) ) { self::init_licenses(); } @@ -356,7 +359,7 @@ public static function get_license_data( string $license ) : array { * @type string $type Schema.org type (Person or Organization). * } */ - private static function get_copyright_holder( \WP_Post $post ) : array { + private static function get_copyright_holder( \WP_Post $post ): array { $options = self::get_options(); switch ( $options['copyright_holder_type'] ) { @@ -394,7 +397,7 @@ private static function get_copyright_holder( \WP_Post $post ) : array { * * @return void */ - public static function output_meta_tags() : void { + public static function output_meta_tags(): void { $options = self::get_options(); // Check if we should output on this post type. @@ -469,7 +472,7 @@ public static function output_meta_tags() : void { * * @return void */ - public static function add_license_metabox() : void { + public static function add_license_metabox(): void { $options = self::get_options(); $post_types = ! empty( $options['post_types'] ) ? $options['post_types'] : array( 'post' ); @@ -489,10 +492,10 @@ public static function add_license_metabox() : void { * @param \WP_Post $post Current post object. * @return void */ - public static function render_license_metabox( \WP_Post $post ) : void { - $options = self::get_options(); - $current = \get_post_meta( $post->ID, '_gt_content_license', true ); - $current = ! empty( $current ) ? $current : $options['default_license']; + public static function render_license_metabox( \WP_Post $post ): void { + $options = self::get_options(); + $current = \get_post_meta( $post->ID, '_gt_content_license', true ); + $current = ! empty( $current ) ? $current : $options['default_license']; $detected_plugin = self::detect_seo_plugin(); \wp_nonce_field( 'gt_license_nonce', 'gt_license_nonce_field' ); @@ -534,7 +537,7 @@ public static function render_license_metabox( \WP_Post $post ) : void { * @param string $plugin Plugin identifier. * @return string Display name. */ - private static function get_plugin_display_name( string $plugin ) : string { + private static function get_plugin_display_name( string $plugin ): string { $names = array( 'rank-math' => 'Rank Math', 'yoast' => 'Yoast SEO', @@ -553,7 +556,7 @@ private static function get_plugin_display_name( string $plugin ) : string { * @param \WP_Post $post Post object. * @return void */ - public static function save_license_meta( int $post_id, \WP_Post $post ) : void { + public static function save_license_meta( int $post_id, \WP_Post $post ): void { // Verify nonce. if ( ! isset( $_POST['gt_license_nonce_field'] ) ) { return; @@ -599,7 +602,7 @@ public static function save_license_meta( int $post_id, \WP_Post $post ) : void * * @return array|null Copyright data or null if not applicable. */ - private static function get_copyright_schema_data() : ?array { + private static function get_copyright_schema_data(): ?array { if ( ! \is_singular() ) { return null; } @@ -639,7 +642,7 @@ private static function get_copyright_schema_data() : ?array { * @param object $jsonld JSON-LD object. * @return array Modified data. */ - public static function add_copyright_to_rank_math( array $data, $jsonld ) : array { + public static function add_copyright_to_rank_math( array $data, $jsonld ): array { $copyright = self::get_copyright_schema_data(); if ( $copyright === null ) { return $data; @@ -668,7 +671,7 @@ public static function add_copyright_to_rank_math( array $data, $jsonld ) : arra * @param array $data Article schema data. * @return array Modified data. */ - public static function add_copyright_to_yoast_article( array $data ) : array { + public static function add_copyright_to_yoast_article( array $data ): array { $copyright = self::get_copyright_schema_data(); if ( $copyright === null ) { return $data; @@ -690,7 +693,7 @@ public static function add_copyright_to_yoast_article( array $data ) : array { * @param array $data WebPage schema data. * @return array Modified data. */ - public static function add_copyright_to_yoast_webpage( array $data ) : array { + public static function add_copyright_to_yoast_webpage( array $data ): array { $copyright = self::get_copyright_schema_data(); if ( $copyright === null ) { return $data; @@ -715,7 +718,7 @@ public static function add_copyright_to_yoast_webpage( array $data ) : array { * @param array $data JSON-LD data. * @return array Modified data. */ - public static function add_copyright_to_seo_framework( array $data ) : array { + public static function add_copyright_to_seo_framework( array $data ): array { $copyright = self::get_copyright_schema_data(); if ( $copyright === null ) { return $data; @@ -745,7 +748,7 @@ public static function add_copyright_to_seo_framework( array $data ) : array { * @param array $schemas Schema data. * @return array Modified schemas. */ - public static function add_copyright_to_seopress( array $schemas ) : array { + public static function add_copyright_to_seopress( array $schemas ): array { $copyright = self::get_copyright_schema_data(); if ( $copyright === null ) { return $schemas; @@ -774,7 +777,7 @@ public static function add_copyright_to_seopress( array $schemas ) : array { * @param array $data Schema data. * @return array Modified data. */ - public static function add_copyright_to_aioseo( array $data ) : array { + public static function add_copyright_to_aioseo( array $data ): array { $copyright = self::get_copyright_schema_data(); if ( $copyright === null ) { return $data; diff --git a/includes/features/class-misc.php b/includes/features/class-misc.php index 63ec424..78985c3 100644 --- a/includes/features/class-misc.php +++ b/includes/features/class-misc.php @@ -94,7 +94,7 @@ class Misc { * * @return void */ - public static function init() : void { + public static function init(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -328,12 +328,12 @@ function () { * * @return array Options array. */ - protected static function get_options() : array { + protected static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( + $defaults = array( 'enabled' => false, 'disable_block_widgets' => false, 'load_separate_core_block_assets' => false, @@ -358,7 +358,7 @@ protected static function get_options() : array { 'enable_prism_admin' => false, 'enable_textarea_fullscreen' => false, ); - $opts = (array) \get_option( 'functionalities_misc', $defaults ); + $opts = (array) \get_option( 'functionalities_misc', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -372,7 +372,7 @@ protected static function get_options() : array { * @param string $key The option key to check. * @return bool True if option is enabled. */ - protected static function is_option_enabled( array $opts, string $key ) : bool { + protected static function is_option_enabled( array $opts, string $key ): bool { $value = ! empty( $opts[ $key ] ); /** @@ -394,7 +394,7 @@ protected static function is_option_enabled( array $opts, string $key ) : bool { * * @return void */ - public static function enqueue_prism() : void { + public static function enqueue_prism(): void { /** * Filters the Prism.js theme CSS URL. * @@ -428,7 +428,7 @@ function () { * @param string $src The source URL. * @return string The source URL without version query string. */ - public static function remove_ver_query_string( string $src ) : string { + public static function remove_ver_query_string( string $src ): string { if ( strpos( $src, 'ver=' ) ) { $src = remove_query_arg( 'ver', $src ); } @@ -443,7 +443,7 @@ public static function remove_ver_query_string( string $src ) : string { * @param array $links Array of links to ping. * @return void */ - public static function disable_self_pings( array &$links ) : void { + public static function disable_self_pings( array &$links ): void { $home = home_url(); foreach ( $links as $l => $link ) { if ( 0 === strpos( $link, $home ) ) { @@ -461,7 +461,7 @@ public static function disable_self_pings( array &$links ) : void { * * @return void */ - protected static function disable_emojis() : void { + protected static function disable_emojis(): void { \remove_action( 'wp_head', 'print_emoji_detection_script', 7 ); \remove_action( 'admin_print_scripts', 'print_emoji_detection_script' ); \remove_action( 'wp_print_styles', 'print_emoji_styles' ); @@ -490,7 +490,7 @@ function ( $plugins ) { * * @return void */ - protected static function disable_embeds() : void { + protected static function disable_embeds(): void { \remove_action( 'wp_head', 'wp_oembed_add_discovery_links' ); \remove_action( 'wp_head', 'wp_oembed_add_host_js' ); \add_filter( 'embed_oembed_discover', '__return_false' ); @@ -520,7 +520,7 @@ function ( $endpoints ) { * * @return void */ - protected static function disable_feeds() : void { + protected static function disable_feeds(): void { $callback = function () { // Redirect to homepage. if ( function_exists( 'wp_safe_redirect' ) ) { diff --git a/includes/features/class-pwa.php b/includes/features/class-pwa.php index bf5de47..5e8a61c 100644 --- a/includes/features/class-pwa.php +++ b/includes/features/class-pwa.php @@ -48,10 +48,18 @@ exit; } +/** + * Progressive Web App endpoints and frontend behavior. + */ class PWA { const REWRITE_VERSION = '1.0.0'; + /** + * Cached module options. + * + * @var array|null + */ private static $options = null; /** @@ -60,8 +68,12 @@ class PWA { * @since 1.1.0 * @return void */ - public static function init() : void { - self::register_routes(); + public static function init(): void { + if ( \did_action( 'init' ) ) { + self::register_routes(); + } else { + \add_action( 'init', array( __CLASS__, 'register_routes' ) ); + } \add_filter( 'query_vars', array( __CLASS__, 'register_query_vars' ) ); \add_action( 'template_redirect', array( __CLASS__, 'handle_endpoints' ) ); \add_action( 'update_option_functionalities_pwa', array( __CLASS__, 'on_option_update' ), 10, 2 ); @@ -82,7 +94,7 @@ public static function init() : void { * @since 1.1.0 * @return array Options array. */ - public static function get_options() : array { + public static function get_options(): array { if ( null !== self::$options ) { return self::$options; } @@ -125,7 +137,7 @@ public static function get_options() : array { 'rewrite_version' => '', ); - $opts = (array) \get_option( 'functionalities_pwa', $defaults ); + $opts = (array) \get_option( 'functionalities_pwa', $defaults ); self::$options = array_merge( $defaults, $opts ); // Prefill empty fields from WordPress settings. @@ -133,7 +145,7 @@ public static function get_options() : array { self::$options['app_name'] = \get_bloginfo( 'name' ); } if ( self::$options['short_name'] === '' ) { - $name = \get_bloginfo( 'name' ); + $name = \get_bloginfo( 'name' ); self::$options['short_name'] = mb_strlen( $name ) > 12 ? mb_substr( $name, 0, 12 ) : $name; } if ( self::$options['description'] === '' ) { @@ -166,7 +178,7 @@ public static function get_options() : array { * @since 1.1.0 * @return bool */ - private static function is_enabled() : bool { + private static function is_enabled(): bool { $opts = self::get_options(); return (bool) \apply_filters( 'functionalities_pwa_enabled', ! empty( $opts['enabled'] ) ); @@ -182,7 +194,7 @@ private static function is_enabled() : bool { * @since 1.1.0 * @return void */ - public static function register_routes() : void { + public static function register_routes(): void { \add_rewrite_rule( '^manifest\.webmanifest$', 'index.php?func_pwa_manifest=1', 'top' ); \add_rewrite_rule( '^functionalities-sw\.js$', 'index.php?func_pwa_sw=1', 'top' ); \add_rewrite_rule( '^functionalities-offline/?$', 'index.php?func_pwa_offline=1', 'top' ); @@ -195,7 +207,7 @@ public static function register_routes() : void { * @param array $vars Existing query vars. * @return array Modified query vars. */ - public static function register_query_vars( array $vars ) : array { + public static function register_query_vars( array $vars ): array { $vars[] = 'func_pwa_manifest'; $vars[] = 'func_pwa_sw'; $vars[] = 'func_pwa_offline'; @@ -210,7 +222,7 @@ public static function register_query_vars( array $vars ) : array { * @since 1.1.0 * @return void */ - public static function handle_endpoints() : void { + public static function handle_endpoints(): void { if ( ! self::is_enabled() ) { return; } @@ -242,7 +254,7 @@ public static function handle_endpoints() : void { * @since 1.1.0 * @return void */ - private static function output_manifest() : void { + private static function output_manifest(): void { $manifest = self::build_manifest(); while ( ob_get_level() ) { @@ -265,7 +277,7 @@ private static function output_manifest() : void { * @since 1.1.0 * @return array Manifest data. */ - private static function build_manifest() : array { + private static function build_manifest(): array { $opts = self::get_options(); $site_name = \get_bloginfo( 'name' ); $site_desc = \get_bloginfo( 'description' ); @@ -336,16 +348,32 @@ private static function build_manifest() : array { * @since 1.1.0 * @return array Icons array. */ - private static function get_manifest_icons() : array { + private static function get_manifest_icons(): array { $opts = self::get_options(); $icons = array(); $seen = array(); $sizes = array( - array( 'key' => 'icon_512', 'size' => '512x512', 'purpose' => 'any' ), - array( 'key' => 'icon_192', 'size' => '192x192', 'purpose' => 'any' ), - array( 'key' => 'maskable_icon_512', 'size' => '512x512', 'purpose' => 'maskable' ), - array( 'key' => 'maskable_icon_192', 'size' => '192x192', 'purpose' => 'maskable' ), + array( + 'key' => 'icon_512', + 'size' => '512x512', + 'purpose' => 'any', + ), + array( + 'key' => 'icon_192', + 'size' => '192x192', + 'purpose' => 'any', + ), + array( + 'key' => 'maskable_icon_512', + 'size' => '512x512', + 'purpose' => 'maskable', + ), + array( + 'key' => 'maskable_icon_192', + 'size' => '192x192', + 'purpose' => 'maskable', + ), ); foreach ( $sizes as $def ) { @@ -390,7 +418,7 @@ private static function get_manifest_icons() : array { * @param mixed $icon Attachment ID or URL. * @return string URL or empty string. */ - private static function get_icon_url( $icon ) : string { + private static function get_icon_url( $icon ): string { if ( empty( $icon ) ) { return ''; } @@ -410,7 +438,7 @@ private static function get_icon_url( $icon ) : string { * @param string $url File URL. * @return string MIME type. */ - private static function get_icon_mime( string $url ) : string { + private static function get_icon_mime( string $url ): string { $ext = strtolower( pathinfo( wp_parse_url( $url, PHP_URL_PATH ) ?: '', PATHINFO_EXTENSION ) ); $map = array( 'png' => 'image/png', @@ -430,7 +458,7 @@ private static function get_icon_mime( string $url ) : string { * @since 1.1.0 * @return array Shortcuts array. */ - private static function get_manifest_shortcuts() : array { + private static function get_manifest_shortcuts(): array { $opts = self::get_options(); $out = array(); @@ -478,7 +506,7 @@ private static function get_manifest_shortcuts() : array { * @since 1.1.0 * @return array Screenshots array. */ - private static function get_manifest_screenshots() : array { + private static function get_manifest_screenshots(): array { $opts = self::get_options(); $out = array(); @@ -524,7 +552,7 @@ private static function get_manifest_screenshots() : array { * @since 1.1.0 * @return array Share target config. */ - private static function build_share_target() : array { + private static function build_share_target(): array { $opts = self::get_options(); $action = trim( (string) ( $opts['share_target_action'] ?? '' ) ); @@ -564,23 +592,25 @@ private static function build_share_target() : array { * @since 1.1.0 * @return void */ - private static function output_service_worker() : void { + private static function output_service_worker(): void { $opts = self::get_options(); $config = array( - 'version' => ! empty( $opts['cache_version'] ) ? $opts['cache_version'] : 'v1', + 'version' => ! empty( $opts['cache_version'] ) ? $opts['cache_version'] : 'v1', 'offline_url' => \home_url( '/functionalities-offline/' ), - 'precache' => self::split_lines( $opts['precache_urls'] ?? '' ), + 'precache' => self::split_lines( $opts['precache_urls'] ?? '' ), ); $config = (array) \apply_filters( 'functionalities_pwa_service_worker_config', $config ); - $precache = array_values( array_unique( - array_merge( - array( \home_url( '/' ), $config['offline_url'] ), - $config['precache'] + $precache = array_values( + array_unique( + array_merge( + array( \home_url( '/' ), $config['offline_url'] ), + $config['precache'] + ) ) - ) ); + ); while ( ob_get_level() ) { ob_end_clean(); @@ -667,7 +697,7 @@ private static function output_service_worker() : void { * @since 1.1.0 * @return void */ - public static function output_head_tags() : void { + public static function output_head_tags(): void { $opts = self::get_options(); $theme_color = ! empty( $opts['theme_color'] ) ? $opts['theme_color'] : '#4f46e5'; @@ -691,7 +721,7 @@ public static function output_head_tags() : void { * @since 1.1.0 * @return void */ - public static function output_prompt_css() : void { + public static function output_prompt_css(): void { $opts = self::get_options(); if ( empty( $opts['install_prompt'] ) ) { return; @@ -726,7 +756,7 @@ public static function output_prompt_css() : void { * @since 1.1.0 * @return void */ - public static function output_install_prompt() : void { + public static function output_install_prompt(): void { $opts = self::get_options(); if ( empty( $opts['install_prompt'] ) ) { return; @@ -761,35 +791,37 @@ public static function output_install_prompt() : void { * @since 1.1.0 * @return void */ - public static function output_frontend_js() : void { + public static function output_frontend_js(): void { $opts = self::get_options(); - $config = \wp_json_encode( array( - 'sw' => \home_url( '/functionalities-sw.js' ), - 'scope' => ! empty( $opts['scope'] ) ? $opts['scope'] : \home_url( '/' ), - 'prompt' => ! empty( $opts['install_prompt'] ), - 'frequency' => (int) ( $opts['prompt_frequency'] ?? 14 ), - ) ); + $config = \wp_json_encode( + array( + 'sw' => \home_url( '/functionalities-sw.js' ), + 'scope' => ! empty( $opts['scope'] ) ? $opts['scope'] : \home_url( '/' ), + 'prompt' => ! empty( $opts['install_prompt'] ), + 'frequency' => (int) ( $opts['prompt_frequency'] ?? 14 ), + ) + ); echo '' . "\n"; } @@ -803,12 +835,12 @@ public static function output_frontend_js() : void { * @since 1.1.0 * @return void */ - private static function output_offline_page() : void { - $opts = self::get_options(); - $site_name = \esc_html( \get_bloginfo( 'name' ) ); - $home = \esc_url( \home_url( '/' ) ); - $theme = \esc_attr( $opts['theme_color'] ?? '#4f46e5' ); - $cache_ver = \esc_js( $opts['cache_version'] ?? 'v1' ); + private static function output_offline_page(): void { + $opts = self::get_options(); + $site_name = \esc_html( \get_bloginfo( 'name' ) ); + $home = \esc_url( \home_url( '/' ) ); + $theme = \esc_attr( $opts['theme_color'] ?? '#4f46e5' ); + $cache_ver = \esc_js( $opts['cache_version'] ?? 'v1' ); while ( ob_get_level() ) { ob_end_clean(); @@ -817,7 +849,8 @@ private static function output_offline_page() : void { \status_header( 503 ); \nocache_headers(); - ?> + ?> + @@ -926,7 +959,7 @@ private static function output_offline_page() : void { * @since 1.1.0 * @return void */ - private static function handle_share_target() : void { + private static function handle_share_target(): void { $opts = self::get_options(); if ( empty( $opts['share_target_enabled'] ) ) { \wp_safe_redirect( \home_url( '/' ) ); @@ -961,7 +994,7 @@ private static function handle_share_target() : void { * @param mixed $value New option value. * @return void */ - public static function on_option_update( $old_value, $value ) : void { + public static function on_option_update( $old_value, $value ): void { self::$options = null; self::flush_rewrites(); } @@ -972,7 +1005,7 @@ public static function on_option_update( $old_value, $value ) : void { * @since 1.1.0 * @return void */ - private static function flush_rewrites() : void { + private static function flush_rewrites(): void { self::register_routes(); \flush_rewrite_rules( false ); } @@ -987,7 +1020,7 @@ private static function flush_rewrites() : void { * @param string $str Input string. * @return array Trimmed, non-empty values. */ - private static function split_list( string $str ) : array { + private static function split_list( string $str ): array { if ( $str === '' ) { return array(); } @@ -1001,7 +1034,7 @@ private static function split_list( string $str ) : array { * @param string $str Input string. * @return array Trimmed, non-empty values. */ - private static function split_lines( string $str ) : array { + private static function split_lines( string $str ): array { if ( trim( $str ) === '' ) { return array(); } diff --git a/includes/features/class-redirect-manager.php b/includes/features/class-redirect-manager.php index 97e5f69..da0c0a8 100644 --- a/includes/features/class-redirect-manager.php +++ b/includes/features/class-redirect-manager.php @@ -26,6 +26,13 @@ class Redirect_Manager { */ private static $redirects_file = ''; + /** + * Bounded 404 log file path. + * + * @var string + */ + private static $log_file = ''; + /** * Cached redirects data. * @@ -33,13 +40,21 @@ class Redirect_Manager { */ private static $redirects_cache = null; + /** + * Last storage error code. + * + * @var string + */ + private static $storage_error = ''; + /** * Initialize the feature. * * @return void */ - public static function init() : void { + public static function init(): void { self::$redirects_file = WP_CONTENT_DIR . '/functionalities/redirects.json'; + self::$log_file = WP_CONTENT_DIR . '/functionalities/404-log.json'; $opts = (array) \get_option( 'functionalities_redirect_manager', array( 'enabled' => false ) ); @@ -49,6 +64,9 @@ public static function init() : void { // Handle redirects early on frontend. \add_action( 'template_redirect', array( __CLASS__, 'handle_redirect' ), 1 ); + if ( ! empty( $opts['monitor_404'] ) ) { + \add_action( 'template_redirect', array( __CLASS__, 'maybe_log_404' ), 99 ); + } // Only register admin handlers in admin. if ( ! \is_admin() ) { @@ -62,6 +80,8 @@ public static function init() : void { \add_action( 'wp_ajax_functionalities_redirect_toggle', array( __CLASS__, 'ajax_toggle_redirect' ) ); \add_action( 'wp_ajax_functionalities_redirect_import', array( __CLASS__, 'ajax_import_redirects' ) ); \add_action( 'wp_ajax_functionalities_redirect_export', array( __CLASS__, 'ajax_export_redirects' ) ); + \add_action( 'wp_ajax_functionalities_redirect_404_purge', array( __CLASS__, 'ajax_purge_404_log' ) ); + \add_action( 'wp_ajax_functionalities_redirect_404_ignore', array( __CLASS__, 'ajax_ignore_404' ) ); } /** @@ -108,37 +128,36 @@ private static function get_redirects_dir() { * * @return array List of redirects. */ - public static function get_redirects() : array { + public static function get_redirects(): array { if ( null !== self::$redirects_cache ) { return self::$redirects_cache; } // Use transient to avoid disk I/O on every request. $cached = \get_transient( 'func_redirects_json' ); - if ( false !== $cached && is_array( $cached ) ) { + if ( ! \is_admin() && false !== $cached && is_array( $cached ) ) { self::$redirects_cache = $cached; return self::$redirects_cache; } - if ( ! file_exists( self::$redirects_file ) ) { - self::$redirects_cache = array(); - return self::$redirects_cache; - } + $result = \Functionalities\Storage\Atomic_JSON_Store::read( + self::$redirects_file, + array( + 'version' => '1.0', + 'modified' => '', + 'redirects' => array(), + ) + ); - $content = file_get_contents( self::$redirects_file ); - if ( false === $content ) { + if ( ! $result['success'] || ! isset( $result['data']['redirects'] ) || ! is_array( $result['data']['redirects'] ) ) { + self::$storage_error = $result['error'] ?: 'invalid_structure'; self::$redirects_cache = array(); return self::$redirects_cache; } - $data = json_decode( $content, true ); - if ( json_last_error() !== JSON_ERROR_NONE || ! isset( $data['redirects'] ) ) { - self::$redirects_cache = array(); - return self::$redirects_cache; - } + self::$storage_error = ''; + self::$redirects_cache = $result['data']['redirects']; - self::$redirects_cache = $data['redirects']; - // Cache for 12 hours. \set_transient( 'func_redirects_json', self::$redirects_cache, 12 * HOUR_IN_SECONDS ); @@ -151,7 +170,7 @@ public static function get_redirects() : array { * @param array $redirects Redirects array. * @return bool True on success. */ - public static function save_redirects( array $redirects ) : bool { + public static function save_redirects( array $redirects ): bool { $dir = self::get_redirects_dir(); if ( ! $dir ) { return false; @@ -163,26 +182,86 @@ public static function save_redirects( array $redirects ) : bool { 'redirects' => array_values( $redirects ), ); - $json = wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); - - $fs = self::get_filesystem(); - $result = $fs ? $fs->put_contents( self::$redirects_file, $json, FS_CHMOD_FILE ) : false; - if ( false !== $result && $result ) { + $result = \Functionalities\Storage\Atomic_JSON_Store::write( self::$redirects_file, $data ); + if ( $result['success'] ) { self::$redirects_cache = $redirects; self::$index = null; // Invalidate index so it's rebuilt on next request. - \set_transient( 'func_redirects_json', $redirects, 12 * HOUR_IN_SECONDS ); + self::$storage_error = ''; + \delete_transient( 'func_redirects_json' ); return true; } + self::$storage_error = $result['error']; return false; } + /** + * Return the last machine-readable storage error. + * + * @return string + */ + public static function get_storage_error(): string { + return self::$storage_error; + } + + /** + * Atomically mutate the redirects list. + * + * @param callable $mutator Redirect-list mutator. + * @return array Storage operation result. + */ + private static function mutate_redirects( callable $mutator ): array { + if ( ! self::get_redirects_dir() ) { + self::$storage_error = 'directory_failed'; + return array( + 'success' => false, + 'data' => array(), + 'error' => self::$storage_error, + 'exists' => false, + ); + } + $default = array( + 'version' => '1.0', + 'modified' => '', + 'redirects' => array(), + ); + + $result = \Functionalities\Storage\Atomic_JSON_Store::update( + self::$redirects_file, + static function ( array $data ) use ( $mutator ) { + $redirects = isset( $data['redirects'] ) && is_array( $data['redirects'] ) ? $data['redirects'] : array(); + $next = call_user_func( $mutator, $redirects ); + + if ( ! is_array( $next ) ) { + return false; + } + + $data['version'] = '1.0'; + $data['modified'] = current_time( 'mysql' ); + $data['redirects'] = array_values( $next ); + return $data; + }, + $default + ); + + if ( $result['success'] ) { + self::$redirects_cache = $result['data']['redirects']; + self::$index = null; + self::$storage_error = ''; + \delete_transient( 'func_redirects_json' ); + } else { + self::$storage_error = $result['error']; + } + + return $result; + } + /** * Generate unique redirect ID. * * @return string Unique ID. */ - private static function generate_id() : string { + private static function generate_id(): string { return 'r_' . substr( md5( uniqid( '', true ) ), 0, 10 ); } @@ -195,21 +274,12 @@ private static function generate_id() : string { * @return array|false Redirect data or false on failure. */ public static function add_redirect( string $from_url, string $to_url, int $type = 301 ) { - $redirects = self::get_redirects(); - // Normalize source URL. $from_url = self::normalize_path( $from_url ); if ( empty( $from_url ) ) { return false; } - // Check for duplicate. - foreach ( $redirects as $redirect ) { - if ( $redirect['from'] === $from_url ) { - return false; // Already exists. - } - } - // Prevent redirect loops (source === destination). $to_path = self::normalize_path( $to_url ); if ( $from_url === $to_path ) { @@ -226,9 +296,22 @@ public static function add_redirect( string $from_url, string $to_url, int $type 'created' => current_time( 'mysql' ), ); - $redirects[] = $redirect; + $added = false; + $result = self::mutate_redirects( + static function ( array $redirects ) use ( $from_url, $redirect, &$added ) { + foreach ( $redirects as $existing ) { + if ( isset( $existing['from'] ) && $from_url === $existing['from'] ) { + return false; + } + } - if ( self::save_redirects( $redirects ) ) { + $redirects[] = $redirect; + $added = true; + return $redirects; + } + ); + + if ( $result['success'] && $added ) { return $redirect; } @@ -243,32 +326,37 @@ public static function add_redirect( string $from_url, string $to_url, int $type * @return array|false Updated redirect or false on failure. */ public static function update_redirect( string $id, array $updates ) { - $redirects = self::get_redirects(); - - foreach ( $redirects as &$redirect ) { - if ( $redirect['id'] === $id ) { - if ( isset( $updates['from'] ) ) { - $redirect['from'] = self::normalize_path( $updates['from'] ); - } - if ( isset( $updates['to'] ) ) { - $redirect['to'] = esc_url_raw( $updates['to'] ); - } - if ( isset( $updates['type'] ) ) { - $type = (int) $updates['type']; - $redirect['type'] = in_array( $type, array( 301, 302, 307, 308 ), true ) ? $type : 301; - } - if ( isset( $updates['enabled'] ) ) { - $redirect['enabled'] = (bool) $updates['enabled']; + $updated = false; + $result = self::mutate_redirects( + static function ( array $redirects ) use ( $id, $updates, &$updated ) { + foreach ( $redirects as &$redirect ) { + if ( ! isset( $redirect['id'] ) || $id !== $redirect['id'] ) { + continue; + } + + if ( isset( $updates['from'] ) ) { + $redirect['from'] = self::normalize_path( $updates['from'] ); + } + if ( isset( $updates['to'] ) ) { + $redirect['to'] = esc_url_raw( $updates['to'] ); + } + if ( isset( $updates['type'] ) ) { + $type = (int) $updates['type']; + $redirect['type'] = in_array( $type, array( 301, 302, 307, 308 ), true ) ? $type : 301; + } + if ( isset( $updates['enabled'] ) ) { + $redirect['enabled'] = (bool) $updates['enabled']; + } + + $updated = $redirect; + return $redirects; } - if ( self::save_redirects( $redirects ) ) { - return $redirect; - } - break; + return false; } - } + ); - return false; + return $result['success'] ? $updated : false; } /** @@ -277,14 +365,23 @@ public static function update_redirect( string $id, array $updates ) { * @param string $id Redirect ID. * @return bool True on success. */ - public static function delete_redirect( string $id ) : bool { - $redirects = self::get_redirects(); - - $redirects = array_filter( $redirects, function( $r ) use ( $id ) { - return $r['id'] !== $id; - }); + public static function delete_redirect( string $id ): bool { + $deleted = false; + $result = self::mutate_redirects( + static function ( array $redirects ) use ( $id, &$deleted ) { + $next = array(); + foreach ( $redirects as $redirect ) { + if ( isset( $redirect['id'] ) && $id === $redirect['id'] ) { + $deleted = true; + continue; + } + $next[] = $redirect; + } + return $deleted ? $next : false; + } + ); - return self::save_redirects( $redirects ); + return $result['success'] && $deleted; } /** @@ -294,18 +391,21 @@ public static function delete_redirect( string $id ) : bool { * @return bool|null New state or null on failure. */ public static function toggle_redirect( string $id ) { - $redirects = self::get_redirects(); $new_state = null; - - foreach ( $redirects as &$redirect ) { - if ( $redirect['id'] === $id ) { - $redirect['enabled'] = ! $redirect['enabled']; - $new_state = $redirect['enabled']; - break; + $result = self::mutate_redirects( + static function ( array $redirects ) use ( $id, &$new_state ) { + foreach ( $redirects as &$redirect ) { + if ( isset( $redirect['id'] ) && $id === $redirect['id'] ) { + $redirect['enabled'] = empty( $redirect['enabled'] ); + $new_state = $redirect['enabled']; + return $redirects; + } + } + return false; } - } + ); - if ( null !== $new_state && self::save_redirects( $redirects ) ) { + if ( $result['success'] && null !== $new_state ) { return $new_state; } @@ -321,7 +421,7 @@ public static function toggle_redirect( string $id ) { * @param string $path URL path. * @return string Normalized path. */ - private static function normalize_path( string $path ) : string { + private static function normalize_path( string $path ): string { // Remove domain if present. $path = preg_replace( '#^https?://[^/]+#i', '', $path ); @@ -357,7 +457,7 @@ private static function normalize_path( string $path ) : string { * * @return void */ - private static function build_index() : void { + private static function build_index(): void { if ( null !== self::$index ) { return; } @@ -383,9 +483,12 @@ private static function build_index() : void { } // Sort wildcards by prefix length descending (most specific first). - usort( self::$index['wildcard'], function ( $a, $b ) { - return strlen( $b['from'] ) - strlen( $a['from'] ); - }); + usort( + self::$index['wildcard'], + function ( $a, $b ) { + return strlen( $b['from'] ) - strlen( $a['from'] ); + } + ); } /** @@ -397,7 +500,7 @@ private static function build_index() : void { * * @return void */ - public static function handle_redirect() : void { + public static function handle_redirect(): void { // Don't redirect in admin. if ( is_admin() ) { return; @@ -447,7 +550,7 @@ public static function handle_redirect() : void { * @param string $query_string Original query string including leading '?', or empty. * @return void */ - private static function do_redirect( array $redirect, string $query_string = '' ) : void { + private static function do_redirect( array $redirect, string $query_string = '' ): void { $destination = $redirect['to']; // Append original query string if the destination has none. @@ -458,7 +561,7 @@ private static function do_redirect( array $redirect, string $query_string = '' // Loop detection: if destination resolves to the same path, bail. $dest_path = self::normalize_path( $destination ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized by normalize_path. - $current = self::normalize_path( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ); + $current = self::normalize_path( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ); if ( $dest_path === $current ) { return; } @@ -484,10 +587,12 @@ private static function do_redirect( array $redirect, string $query_string = '' * @param string $id Redirect ID. * @return void */ - private static function defer_hit_increment( string $id ) : void { - \register_shutdown_function( function () use ( $id ) { - self::increment_hits( $id ); - }); + private static function defer_hit_increment( string $id ): void { + \register_shutdown_function( + function () use ( $id ) { + self::increment_hits( $id ); + } + ); } /** @@ -496,21 +601,18 @@ private static function defer_hit_increment( string $id ) : void { * @param string $id Redirect ID. * @return void */ - private static function increment_hits( string $id ) : void { - // Reset cache so we read fresh data (shutdown context). - self::$redirects_cache = null; - \delete_transient( 'func_redirects_json' ); - - $redirects = self::get_redirects(); - - foreach ( $redirects as &$redirect ) { - if ( $redirect['id'] === $id ) { - $redirect['hits'] = ( $redirect['hits'] ?? 0 ) + 1; - break; + private static function increment_hits( string $id ): void { + self::mutate_redirects( + static function ( array $redirects ) use ( $id ) { + foreach ( $redirects as &$redirect ) { + if ( isset( $redirect['id'] ) && $id === $redirect['id'] ) { + $redirect['hits'] = (int) ( $redirect['hits'] ?? 0 ) + 1; + return $redirects; + } + } + return false; } - } - - self::save_redirects( $redirects ); + ); } /** @@ -518,7 +620,7 @@ private static function increment_hits( string $id ) : void { * * @return array Statistics. */ - public static function get_stats() : array { + public static function get_stats(): array { $redirects = self::get_redirects(); $total = count( $redirects ); @@ -527,7 +629,7 @@ public static function get_stats() : array { foreach ( $redirects as $r ) { if ( ! empty( $r['enabled'] ) ) { - $enabled++; + ++$enabled; } $hits += $r['hits'] ?? 0; } @@ -540,6 +642,299 @@ public static function get_stats() : array { ); } + /** + * Parse a CSV export from common redirect plugins. + * + * @param string $csv CSV document. + * @param array $mapping Optional source, target, and type header mapping. + * @return array Parsed rows and errors. + */ + public static function parse_csv( string $csv, array $mapping = array() ): array { + $lines = preg_split( '/\r\n|\r|\n/', trim( $csv ) ); + if ( ! $lines || count( $lines ) < 2 ) { + return array( + 'rows' => array(), + 'errors' => array( 'csv_requires_header_and_row' ), + ); + } + + $headers = array_map( 'sanitize_key', str_getcsv( array_shift( $lines ), ',', '"', '\\' ) ); + $aliases = array( + 'from' => array( 'source', 'from', 'source_url', 'old_url', 'request', 'url' ), + 'to' => array( 'target', 'to', 'target_url', 'new_url', 'destination', 'redirect_to' ), + 'type' => array( 'type', 'status', 'status_code', 'code', 'action_code' ), + ); + $indexes = array(); + foreach ( $aliases as $field => $names ) { + $requested = isset( $mapping[ $field ] ) ? sanitize_key( $mapping[ $field ] ) : ''; + $candidates = $requested ? array( $requested ) : $names; + foreach ( $candidates as $candidate ) { + $index = array_search( $candidate, $headers, true ); + if ( false !== $index ) { + $indexes[ $field ] = $index; + break; + } + } + } + + if ( ! isset( $indexes['from'], $indexes['to'] ) ) { + return array( + 'rows' => array(), + 'errors' => array( 'missing_source_or_target_column' ), + 'headers' => $headers, + ); + } + + $rows = array(); + foreach ( $lines as $line_number => $line ) { + if ( '' === trim( $line ) ) { + continue; + } + $columns = str_getcsv( $line, ',', '"', '\\' ); + $rows[] = array( + 'from' => $columns[ $indexes['from'] ] ?? '', + 'to' => $columns[ $indexes['to'] ] ?? '', + 'type' => isset( $indexes['type'], $columns[ $indexes['type'] ] ) ? (int) $columns[ $indexes['type'] ] : 301, + 'line' => $line_number + 2, + ); + } + + return array( + 'rows' => $rows, + 'errors' => array(), + 'headers' => $headers, + ); + } + + /** + * Validate and normalize an all-or-nothing redirect import. + * + * @param array $rows Candidate redirect rows. + * @param array $existing Existing redirects, or current redirects when omitted. + * @return array Import preview. + */ + public static function prepare_import( array $rows, ?array $existing = null ): array { + $existing = null === $existing ? self::get_redirects() : $existing; + $sources = array(); + $targets = array(); + $errors = array(); + $warnings = array(); + $clean = array(); + + foreach ( $existing as $redirect ) { + if ( isset( $redirect['from'] ) ) { + $sources[ $redirect['from'] ] = true; + } + } + + foreach ( $rows as $index => $row ) { + $line = isset( $row['line'] ) ? (int) $row['line'] : $index + 1; + $from = self::normalize_path( (string) ( $row['from'] ?? '' ) ); + $to = esc_url_raw( trim( (string) ( $row['to'] ?? '' ) ) ); + $type = (int) ( $row['type'] ?? 301 ); + + if ( '/' === $from || '' === $to ) { + $errors[] = array( + 'line' => $line, + 'code' => 'missing_source_or_target', + ); + continue; + } + if ( false !== strpos( rtrim( $from, '*' ), '*' ) ) { + $errors[] = array( + 'line' => $line, + 'code' => 'wildcard_must_be_last', + ); + continue; + } + if ( isset( $sources[ $from ] ) ) { + $errors[] = array( + 'line' => $line, + 'code' => 'duplicate_source', + 'source' => $from, + ); + continue; + } + if ( self::normalize_path( $to ) === $from ) { + $errors[] = array( + 'line' => $line, + 'code' => 'redirect_loop', + 'source' => $from, + ); + continue; + } + + $sources[ $from ] = true; + $targets[ $from ] = self::normalize_path( $to ); + $clean[] = array( + 'id' => self::generate_id(), + 'from' => $from, + 'to' => $to, + 'type' => in_array( $type, array( 301, 302, 307, 308 ), true ) ? $type : 301, + 'enabled' => true, + 'hits' => 0, + 'created' => current_time( 'mysql' ), + ); + } + + foreach ( $targets as $from => $target ) { + if ( isset( $sources[ $target ] ) ) { + $warnings[] = array( + 'code' => 'redirect_chain', + 'source' => $from, + 'via' => $target, + ); + } + } + + return array( + 'success' => empty( $errors ), + 'rows' => $clean, + 'errors' => $errors, + 'warnings' => $warnings, + 'count' => count( $clean ), + ); + } + + /** + * Atomically append a validated import. + * + * @param array $rows Normalized rows from prepare_import(). + * @return bool + */ + private static function apply_import( array $rows ): bool { + $result = self::mutate_redirects( + static function ( array $redirects ) use ( $rows ) { + $existing = array(); + foreach ( $redirects as $redirect ) { + $existing[ $redirect['from'] ] = true; + } + foreach ( $rows as $row ) { + if ( isset( $existing[ $row['from'] ] ) ) { + return false; + } + $existing[ $row['from'] ] = true; + $redirects[] = $row; + } + return $redirects; + } + ); + return $result['success']; + } + + /** + * Record a privacy-conscious 404 aggregate when monitoring is enabled. + * + * @return void + */ + public static function maybe_log_404(): void { + if ( ! \is_404() || \is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { + return; + } + + $options = (array) \get_option( 'functionalities_redirect_manager', array() ); + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Used only for bot classification. + $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? \wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) : ''; + if ( '' === $agent || preg_match( '/bot|crawl|spider|slurp|preview|monitor/i', $agent ) ) { + return; + } + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Normalized immediately. + $path = self::normalize_path( isset( $_SERVER['REQUEST_URI'] ) ? \wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ); + if ( in_array( $path, (array) ( $options['monitor_ignored_paths'] ?? array() ), true ) ) { + return; + } + if ( self::is_excluded_404_path( $path, (string) ( $options['monitor_exclusions'] ?? '' ) ) ) { + return; + } + + $origin = ''; + if ( ! empty( $_SERVER['HTTP_REFERER'] ) ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only the sanitized host is retained. + $origin = sanitize_text_field( (string) wp_parse_url( \wp_unslash( $_SERVER['HTTP_REFERER'] ), PHP_URL_HOST ) ); + } + + $cap = max( 25, min( 2000, (int) ( $options['monitor_cap'] ?? 500 ) ) ); + $retention = max( 1, min( 365, (int) ( $options['monitor_retention_days'] ?? 30 ) ) ); + $cutoff = time() - ( $retention * DAY_IN_SECONDS ); + \Functionalities\Storage\Atomic_JSON_Store::update( + self::$log_file, + static function ( array $data ) use ( $path, $origin, $cap, $cutoff ) { + $items = isset( $data['items'] ) && is_array( $data['items'] ) ? $data['items'] : array(); + $items = array_filter( + $items, + static function ( array $item ) use ( $cutoff ) { + return (int) ( $item['last_seen'] ?? 0 ) >= $cutoff; + } + ); + if ( isset( $items[ $path ] ) ) { + $items[ $path ]['count'] = (int) $items[ $path ]['count'] + 1; + $items[ $path ]['last_seen'] = time(); + if ( $origin ) { + $items[ $path ]['referrer_origin'] = $origin; + } + } else { + $items[ $path ] = array( + 'path' => $path, + 'count' => 1, + 'last_seen' => time(), + 'referrer_origin' => $origin, + ); + } + uasort( + $items, + static function ( array $a, array $b ) { + return (int) $b['last_seen'] <=> (int) $a['last_seen']; + } + ); + $data['items'] = array_slice( $items, 0, $cap, true ); + return $data; + }, + array( + 'version' => 1, + 'items' => array(), + ) + ); + } + + /** + * Read the current bounded 404 log. + * + * @return array + */ + public static function get_404_log(): array { + $result = \Functionalities\Storage\Atomic_JSON_Store::read( + self::$log_file, + array( + 'version' => 1, + 'items' => array(), + ) + ); + return $result['success'] && isset( $result['data']['items'] ) ? array_values( $result['data']['items'] ) : array(); + } + + /** + * Check built-in and configured 404 exclusions. + * + * @param string $path Normalized request path. + * @param string $additional Newline-separated prefixes. + * @return bool + */ + private static function is_excluded_404_path( string $path, string $additional ): bool { + $prefixes = array( '/wp-admin', '/wp-json', '/xmlrpc.php', '/wp-login.php', '/favicon.ico', '/robots.txt' ); + foreach ( preg_split( '/\r\n|\r|\n/', $additional ) as $prefix ) { + if ( '' !== trim( $prefix ) ) { + $prefixes[] = self::normalize_path( trim( $prefix ) ); + } + } + foreach ( $prefixes as $prefix ) { + if ( 0 === strpos( $path, $prefix ) ) { + return true; + } + } + return false; + } + // AJAX Handlers. /** @@ -547,7 +942,7 @@ public static function get_stats() : array { * * @return bool True if valid. */ - private static function verify_ajax() : bool { + private static function verify_ajax(): bool { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce doesn't need sanitization. $nonce = isset( $_POST['nonce'] ) ? wp_unslash( $_POST['nonce'] ) : ''; if ( empty( $nonce ) || ! \wp_verify_nonce( $nonce, 'functionalities_redirect_manager' ) ) { @@ -566,7 +961,7 @@ private static function verify_ajax() : bool { /** * AJAX: Add redirect. */ - public static function ajax_add_redirect() : void { + public static function ajax_add_redirect(): void { if ( ! self::verify_ajax() ) { return; } @@ -574,7 +969,7 @@ public static function ajax_add_redirect() : void { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax(). $from = isset( $_POST['from'] ) ? sanitize_text_field( wp_unslash( $_POST['from'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax(). - $to = isset( $_POST['to'] ) ? esc_url_raw( wp_unslash( $_POST['to'] ) ) : ''; + $to = isset( $_POST['to'] ) ? esc_url_raw( wp_unslash( $_POST['to'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax(). $type = isset( $_POST['type'] ) ? (int) $_POST['type'] : 301; @@ -585,10 +980,12 @@ public static function ajax_add_redirect() : void { $redirect = self::add_redirect( $from, $to, $type ); if ( $redirect ) { - \wp_send_json_success( array( - 'message' => \__( 'Redirect added.', 'functionalities' ), - 'redirect' => $redirect, - )); + \wp_send_json_success( + array( + 'message' => \__( 'Redirect added.', 'functionalities' ), + 'redirect' => $redirect, + ) + ); } else { \wp_send_json_error( array( 'message' => \__( 'Failed to add redirect. URL may already exist.', 'functionalities' ) ) ); } @@ -597,7 +994,7 @@ public static function ajax_add_redirect() : void { /** * AJAX: Update redirect. */ - public static function ajax_update_redirect() : void { + public static function ajax_update_redirect(): void { if ( ! self::verify_ajax() ) { return; } @@ -629,10 +1026,12 @@ public static function ajax_update_redirect() : void { $redirect = self::update_redirect( $id, $updates ); if ( $redirect ) { - \wp_send_json_success( array( - 'message' => \__( 'Redirect updated.', 'functionalities' ), - 'redirect' => $redirect, - )); + \wp_send_json_success( + array( + 'message' => \__( 'Redirect updated.', 'functionalities' ), + 'redirect' => $redirect, + ) + ); } else { \wp_send_json_error( array( 'message' => \__( 'Failed to update redirect.', 'functionalities' ) ) ); } @@ -641,7 +1040,7 @@ public static function ajax_update_redirect() : void { /** * AJAX: Delete redirect. */ - public static function ajax_delete_redirect() : void { + public static function ajax_delete_redirect(): void { if ( ! self::verify_ajax() ) { return; } @@ -664,7 +1063,7 @@ public static function ajax_delete_redirect() : void { /** * AJAX: Toggle redirect. */ - public static function ajax_toggle_redirect() : void { + public static function ajax_toggle_redirect(): void { if ( ! self::verify_ajax() ) { return; } @@ -679,10 +1078,12 @@ public static function ajax_toggle_redirect() : void { $new_state = self::toggle_redirect( $id ); if ( null !== $new_state ) { - \wp_send_json_success( array( - 'message' => \__( 'Redirect updated.', 'functionalities' ), - 'enabled' => $new_state, - )); + \wp_send_json_success( + array( + 'message' => \__( 'Redirect updated.', 'functionalities' ), + 'enabled' => $new_state, + ) + ); } else { \wp_send_json_error( array( 'message' => \__( 'Failed to toggle redirect.', 'functionalities' ) ) ); } @@ -691,66 +1092,161 @@ public static function ajax_toggle_redirect() : void { /** * AJAX: Import redirects. */ - public static function ajax_import_redirects() : void { + public static function ajax_import_redirects(): void { if ( ! self::verify_ajax() ) { return; } - // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in verify_ajax(). JSON needs to be parsed raw. - $json = isset( $_POST['json'] ) ? wp_unslash( $_POST['json'] ) : ''; - $json = sanitize_textarea_field( $json ); - if ( empty( $json ) ) { - \wp_send_json_error( array( 'message' => \__( 'JSON data is required.', 'functionalities' ) ) ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce verified in verify_ajax(); document is parsed and validated below. + $document = isset( $_POST['document'] ) ? wp_unslash( $_POST['document'] ) : ( isset( $_POST['json'] ) ? wp_unslash( $_POST['json'] ) : '' ); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax(). + $format = isset( $_POST['format'] ) ? sanitize_key( $_POST['format'] ) : 'json'; + if ( empty( $document ) ) { + \wp_send_json_error( array( 'message' => \__( 'Import data is required.', 'functionalities' ) ) ); return; } - $data = json_decode( $json, true ); - if ( json_last_error() !== JSON_ERROR_NONE ) { - \wp_send_json_error( array( 'message' => \__( 'Invalid JSON format.', 'functionalities' ) . ' ' . json_last_error_msg() ) ); - return; + if ( 'csv' === $format ) { + $parsed = self::parse_csv( $document ); + if ( ! empty( $parsed['errors'] ) ) { + \wp_send_json_error( $parsed ); + } + $rows = $parsed['rows']; + } else { + $data = json_decode( $document, true ); + if ( JSON_ERROR_NONE !== json_last_error() ) { + \wp_send_json_error( array( 'message' => \__( 'Invalid JSON format.', 'functionalities' ) . ' ' . json_last_error_msg() ) ); + } + $rows = isset( $data['redirects'] ) ? $data['redirects'] : $data; } - $imported = 0; - $redirects_to_import = isset( $data['redirects'] ) ? $data['redirects'] : $data; - - if ( ! is_array( $redirects_to_import ) ) { + if ( ! is_array( $rows ) ) { \wp_send_json_error( array( 'message' => \__( 'Invalid redirect data format.', 'functionalities' ) ) ); - return; } - - foreach ( $redirects_to_import as $r ) { - if ( isset( $r['from'], $r['to'] ) ) { - $type = isset( $r['type'] ) ? (int) $r['type'] : 301; - if ( self::add_redirect( $r['from'], $r['to'], $type ) ) { - $imported++; - } - } + $preview = self::prepare_import( $rows ); + if ( ! $preview['success'] ) { + \wp_send_json_error( $preview ); } - - \wp_send_json_success( array( - /* translators: %d: Number of redirects imported. */ - 'message' => sprintf( \__( 'Imported %d redirect(s).', 'functionalities' ), $imported ), - 'count' => $imported, - )); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax(). + if ( ! empty( $_POST['dry_run'] ) ) { + \wp_send_json_success( $preview ); + } + if ( ! self::apply_import( $preview['rows'] ) ) { + \wp_send_json_error( array( 'message' => \__( 'The redirect set changed before import. Preview it again.', 'functionalities' ) ) ); + } + \wp_send_json_success( + array( + /* translators: %d: Number of redirects imported. */ + 'message' => sprintf( \__( 'Imported %d redirect(s).', 'functionalities' ), $preview['count'] ), + 'count' => $preview['count'], + 'warnings' => $preview['warnings'], + ) + ); } /** * AJAX: Export redirects. */ - public static function ajax_export_redirects() : void { + public static function ajax_export_redirects(): void { if ( ! self::verify_ajax() ) { return; } $redirects = self::get_redirects(); + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax(). + $format = isset( $_POST['format'] ) ? sanitize_key( $_POST['format'] ) : 'json'; + if ( 'csv' === $format ) { + $lines = array( 'source,target,type,enabled,hits' ); + foreach ( $redirects as $redirect ) { + $lines[] = implode( + ',', + array( + self::csv_cell( $redirect['from'] ?? '' ), + self::csv_cell( $redirect['to'] ?? '' ), + (int) ( $redirect['type'] ?? 301 ), + empty( $redirect['enabled'] ) ? 0 : 1, + (int) ( $redirect['hits'] ?? 0 ), + ) + ); + } + \wp_send_json_success( + array( + 'content' => implode( "\r\n", $lines ) . "\r\n", + 'filename' => 'functionalities-redirects.csv', + ) + ); + } $data = array( 'version' => '1.0', 'exported' => current_time( 'mysql' ), 'redirects' => $redirects, ); - \wp_send_json_success( array( - 'json' => wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), - )); + \wp_send_json_success( + array( + 'json' => wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), + 'content' => wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), + 'filename' => 'functionalities-redirects.json', + ) + ); + } + + /** + * Purge the bounded 404 log. + * + * @return void + */ + public static function ajax_purge_404_log(): void { + if ( ! self::verify_ajax() ) { + return; + } + $result = \Functionalities\Storage\Atomic_JSON_Store::write( + self::$log_file, + array( + 'version' => 1, + 'items' => array(), + ) + ); + $result['success'] ? \wp_send_json_success() : \wp_send_json_error( array( 'message' => $result['error'] ) ); + } + + /** + * Remove one path from the 404 log. + * + * @return void + */ + public static function ajax_ignore_404(): void { + if ( ! self::verify_ajax() ) { + return; + } + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified in verify_ajax(). + $path = isset( $_POST['path'] ) ? self::normalize_path( sanitize_text_field( wp_unslash( $_POST['path'] ) ) ) : ''; + $options = (array) \get_option( 'functionalities_redirect_manager', array() ); + $ignored = (array) ( $options['monitor_ignored_paths'] ?? array() ); + $ignored[] = $path; + $options['monitor_ignored_paths'] = array_slice( array_values( array_unique( array_filter( $ignored ) ) ), -500 ); + \update_option( 'functionalities_redirect_manager', $options ); + $result = \Functionalities\Storage\Atomic_JSON_Store::update( + self::$log_file, + static function ( array $data ) use ( $path ) { + unset( $data['items'][ $path ] ); + return $data; + }, + array( + 'version' => 1, + 'items' => array(), + ) + ); + $result['success'] ? \wp_send_json_success() : \wp_send_json_error( array( 'message' => $result['error'] ) ); + } + + /** + * Escape one RFC 4180-compatible CSV cell. + * + * @param mixed $value Cell value. + * @return string + */ + private static function csv_cell( $value ): string { + return '"' . str_replace( '"', '""', (string) $value ) . '"'; } } diff --git a/includes/features/class-schema.php b/includes/features/class-schema.php index 8f57b9b..2a47abd 100644 --- a/includes/features/class-schema.php +++ b/includes/features/class-schema.php @@ -105,7 +105,7 @@ class Schema { * * @return void */ - public static function init() : void { + public static function init(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -146,25 +146,25 @@ public static function init() : void { * * @return array Options array. */ - protected static function get_options() : array { + protected static function get_options(): array { if ( null !== self::$options ) { return self::$options; } - $defaults = array( - 'enabled' => false, - 'enable_site_schema' => true, - 'site_itemtype' => 'WebPage', - 'enable_header_part' => true, - 'enable_footer_part' => true, - 'enable_article' => true, - 'article_itemtype' => 'Article', - 'add_headline' => true, - 'add_dates' => true, - 'add_author' => true, - 'enable_breadcrumbs' => false, + $defaults = array( + 'enabled' => false, + 'enable_site_schema' => true, + 'site_itemtype' => 'WebPage', + 'enable_header_part' => true, + 'enable_footer_part' => true, + 'enable_article' => true, + 'article_itemtype' => 'Article', + 'add_headline' => true, + 'add_dates' => true, + 'add_author' => true, + 'enable_breadcrumbs' => false, ); - $opts = (array) \get_option( 'functionalities_schema', $defaults ); + $opts = (array) \get_option( 'functionalities_schema', $defaults ); self::$options = array_merge( $defaults, $opts ); return self::$options; } @@ -181,7 +181,7 @@ protected static function get_options() : array { * @param string $output The language attributes string. * @return string Modified attributes string. */ - public static function filter_language_attributes( string $output ) : string { + public static function filter_language_attributes( string $output ): string { $opts = self::get_options(); /** @@ -236,7 +236,7 @@ public static function filter_language_attributes( string $output ) : string { * * @return void */ - public static function start_buffer() : void { + public static function start_buffer(): void { if ( \is_admin() || \is_feed() ) { return; } @@ -294,7 +294,7 @@ public static function end_buffer(): void { * @param string $html The complete page HTML. * @return string Modified HTML with schema attributes. */ - public static function buffer_callback( string $html ) : string { + public static function buffer_callback( string $html ): string { $opts = self::get_options(); $enable_header = ! empty( $opts['enable_header_part'] ); @@ -328,7 +328,7 @@ public static function buffer_callback( string $html ) : string { * @param string $content The post content. * @return string Modified content with schema attributes. */ - public static function filter_article( string $content ) : string { + public static function filter_article( string $content ): string { if ( trim( $content ) === '' || ! \is_singular() ) { return $content; } @@ -453,7 +453,7 @@ public static function filter_article( string $content ) : string { * @since 0.13.0 * @return void */ - public static function output_breadcrumbs() : void { + public static function output_breadcrumbs(): void { if ( ! \is_singular() || \is_front_page() ) { return; } @@ -493,7 +493,7 @@ public static function output_breadcrumbs() : void { foreach ( $ancestors as $ancestor_id ) { $items[] = array( '@type' => 'ListItem', - 'position' => $pos ++, + 'position' => $pos++, 'name' => \get_the_title( $ancestor_id ), 'item' => \get_permalink( $ancestor_id ), ); diff --git a/includes/features/class-snippets.php b/includes/features/class-snippets.php index b515837..91cc4be 100644 --- a/includes/features/class-snippets.php +++ b/includes/features/class-snippets.php @@ -129,7 +129,7 @@ class Snippets { * * @return void */ - public static function init() : void { + public static function init(): void { $opts = self::get_options(); if ( empty( $opts['enabled'] ) ) { @@ -152,7 +152,7 @@ public static function init() : void { * * @return array Options array. */ - protected static function get_options() : array { + protected static function get_options(): array { if ( null !== self::$options ) { return self::$options; } @@ -165,7 +165,7 @@ protected static function get_options() : array { 'body_open' => array(), 'footer' => array(), ); - $opts = (array) \get_option( 'functionalities_snippets', $defaults ); + $opts = (array) \get_option( 'functionalities_snippets', $defaults ); // In-memory migration from old single-string format. if ( isset( $opts['header_code'] ) || isset( $opts['enable_header'] ) ) { @@ -184,7 +184,7 @@ protected static function get_options() : array { * @param array $opts Old-format options. * @return array New-format options. */ - public static function migrate_options( array $opts ) : array { + public static function migrate_options( array $opts ): array { $migrated = array( 'enabled' => ! empty( $opts['enabled'] ), 'enable_ga4' => ! empty( $opts['enable_ga4'] ), @@ -229,7 +229,7 @@ public static function migrate_options( array $opts ) : array { * * @return bool True if snippets should be output. */ - protected static function should_output() : bool { + protected static function should_output(): bool { $is_rest = \defined( 'REST_REQUEST' ) && \constant( 'REST_REQUEST' ); if ( \is_admin() || \is_feed() || $is_rest ) { return false; @@ -255,7 +255,7 @@ protected static function should_output() : bool { * @param string $comment HTML comment label. * @return void */ - private static function output_snippets( string $location, string $filter, string $comment ) : void { + private static function output_snippets( string $location, string $filter, string $comment ): void { $opts = self::get_options(); $snippets = ! empty( $opts[ $location ] ) && \is_array( $opts[ $location ] ) ? $opts[ $location ] : array(); $parts = array(); @@ -302,21 +302,21 @@ private static function output_snippets( string $location, string $filter, strin * * @return void */ - public static function output_head() : void { + public static function output_head(): void { if ( ! self::should_output() ) { return; } $opts = self::get_options(); - /** @since 0.8.0 */ + /** Fires before header snippets are printed. @since 0.8.0 */ \do_action( 'functionalities_before_header_snippets' ); // Output Google Analytics 4. if ( ! empty( $opts['enable_ga4'] ) && ! empty( $opts['ga4_id'] ) ) { $ga4_id = preg_replace( '/[^A-Z0-9\-]/', '', strtoupper( (string) $opts['ga4_id'] ) ); - /** @since 0.8.0 */ + /** Filters whether the configured GA4 tag is printed. @since 0.8.0 */ $ga4_enabled = \apply_filters( 'functionalities_snippets_ga4_enabled', true, $ga4_id ); if ( $ga4_enabled && $ga4_id !== '' ) { @@ -330,7 +330,7 @@ public static function output_head() : void { self::output_snippets( 'header', 'functionalities_snippets_header_code', 'Custom Header Code' ); - /** @since 0.8.0 */ + /** Fires after header snippets are printed. @since 0.8.0 */ \do_action( 'functionalities_after_header_snippets' ); } @@ -343,17 +343,17 @@ public static function output_head() : void { * * @return void */ - public static function output_footer() : void { + public static function output_footer(): void { if ( ! self::should_output() ) { return; } - /** @since 0.8.0 */ + /** Fires before footer snippets are printed. @since 0.8.0 */ \do_action( 'functionalities_before_footer_snippets' ); self::output_snippets( 'footer', 'functionalities_snippets_footer_code', 'Custom Footer Code' ); - /** @since 0.8.0 */ + /** Fires after footer snippets are printed. @since 0.8.0 */ \do_action( 'functionalities_after_footer_snippets' ); } @@ -365,7 +365,7 @@ public static function output_footer() : void { * * @return void */ - public static function output_body_open() : void { + public static function output_body_open(): void { if ( ! self::should_output() ) { return; } @@ -399,10 +399,10 @@ public static function snippet_allowed_tags(): array { ), 'noscript' => array(), 'style' => array( - 'type' => true, - 'media' => true, - 'id' => true, - 'nonce' => true, + 'type' => true, + 'media' => true, + 'id' => true, + 'nonce' => true, 'data-*' => true, ), 'link' => array( @@ -484,9 +484,9 @@ public static function kses_with_styles( string $code, array $allowed_tags ): st // Per-call random token so a snippet body containing a literal placeholder // cannot collide with the substitution markers injected below. // Format: ___FUNC_STYLE_{random}_{index}___ - $token = function_exists( '\wp_generate_password' ) + $token = function_exists( '\wp_generate_password' ) ? \wp_generate_password( 16, false, false ) - : substr( md5( (string) mt_rand() . microtime() ), 0, 16 ); + : substr( md5( (string) wp_rand() . microtime() ), 0, 16 ); $prefix = '___FUNC_STYLE_' . $token . '_'; $suffix = '___'; @@ -497,7 +497,10 @@ public static function kses_with_styles( string $code, array $allowed_tags ): st } // Extract