From c5cdccd3bd9a2841c8787798a671965d9c556187 Mon Sep 17 00:00:00 2001
From: Gaurav Tiwari
Date: Sun, 6 Sep 2026 09:28:49 +0530
Subject: [PATCH 1/4] Ship 1.6.0: Abilities permission fix, private data
storage, HTML API filters
Closes every finding from the v1.5.0 audit.
Security
- Abilities API: a permission callback per ability instead of one shared
callback that downgraded to edit_post whenever the input carried a post_id.
Every input schema now sets additionalProperties => false, and
check-content-integrity also verifies the post type. A contributor with one
draft could previously toggle modules, create redirects and tasks, trigger
scans, and spend AI credits.
- Redirects, the 404 log, and task projects move to a private directory with a
random name under wp-content/functionalities/, with Apache, IIS, and listing
guards written alongside and automatic migration of existing files. A new
async Site Health check confirms over HTTP whether the folder is served.
- Header and footer snippets are filtered once at save time against the
author's unfiltered_html, not at output time against the viewer's. Logged-out
visitors were receiving mangled code: && became && and comparison
operators were eaten as tags, while the administrator saw it work.
- Login Security gains username throttling, an IP allowlist, an unlock action
on the lockout log, and a warning when every recent lockout shares one
address.
- Prism.js is bundled locally instead of loaded from a third-party CDN.
- The service worker no longer caches wp-admin, the login page, REST responses,
cross-origin requests, or anything marked no-store; the runtime cache is
capped and precaching is per URL so one stale entry cannot abort install.
Correctness and performance
- A JSON exception preset served from a URL is cached rather than fetched on
every page load, and the cache clears whenever a post or page is edited.
- Link Management, Block Cleanup, and Schema use WP_HTML_Tag_Processor.
Attributes are edited in place, so Vue, Alpine, and mustache syntax survive
and the skip guard added in 1.4.3 and 1.4.4 is gone.
- Redirect hits and 404 aggregates are buffered and flushed in batches instead
of rewriting the whole JSON file under a lock per request.
- Redirects run at parse_request, guarded against WordPress's own entry points.
- The bulk nofollow tool pages by ID cursor and can now finish past 100 posts.
- The Content Integrity list column reads a result cached at save time.
- The SVG icon library leaves the autoload set, which needed a real upgrade
routine because update_option() returns early on an unchanged value.
- Content Integrity and Assumption Detection gained the filters their docs
promised; the docs no longer list 19 hooks that were never fired.
Release hygiene
- .distignore patterns anchored to the root; an unanchored vendor rule also
matched assets/vendor and would have dropped Prism from the zip. build.sh
now shares the release workflow's exclude list and asserts required files.
- Translation template generated from source: 1,136 strings, was 1 placeholder.
- CI sparse-checks out wordpress-develop so the HTML API tests run, diffs the
POT file, and asserts the new runtime files ship.
Verified on WordPress 7.0.2: 52 tests, 203 assertions, PHPCS clean, lint clean
on PHP 8.5, and an end-to-end pass on a live install covering the permission
model, the storage migration, snippet output, all three content filters, hit
buffering, and the admin screens.
Co-Authored-By: Claude Opus 5
---
.distignore | 39 +-
.github/workflows/quality.yml | 26 +-
.github/workflows/release.yml | 3 +
README.md | 71 +-
ROADMAP.md | 34 +-
assets/blocks/svg-icon/block.json | 2 +-
build.sh | 35 +-
functionalities.php | 6 +-
includes/admin/class-module-controller.php | 131 +-
includes/admin/class-module-docs.php | 86 +-
.../class-settings-portability-controller.php | 4 +-
.../admin/class-site-health-controller.php | 59 +-
includes/admin/trait-admin-ajax.php | 7 +-
includes/admin/trait-admin-sanitizers.php | 19 +-
includes/core/class-upgrader.php | 98 +
.../core/class-wordpress-7-integration.php | 127 +-
.../features/class-assumption-detection.php | 101 +-
includes/features/class-block-cleanup.php | 133 +-
.../features/class-content-regression.php | 119 +-
includes/features/class-link-management.php | 354 +-
includes/features/class-login-security.php | 193 +-
includes/features/class-misc.php | 59 +-
includes/features/class-pwa.php | 63 +-
includes/features/class-redirect-manager.php | 243 +-
includes/features/class-schema.php | 110 +-
includes/features/class-snippets.php | 38 +-
includes/features/class-svg-icons.php | 12 +-
includes/features/class-task-manager.php | 34 +-
includes/storage/class-data-directory.php | 214 +
includes/traits/trait-has-dom-parser.php | 44 -
languages/functionalities.pot | 4940 ++++++++++++++++-
package-lock.json | 4 +-
package.json | 2 +-
readme.txt | 28 +-
tests/AbilitiesPermissionTest.php | 157 +
tests/ContentFilterTest.php | 189 +
tests/LinkPresetCacheTest.php | 133 +
tests/LoginSecurityTest.php | 140 +
tests/PwaOfflineStatusTest.php | 20 +-
tests/SettingsPortabilityTest.php | 32 +-
tests/SnippetsOutputTest.php | 121 +
tests/VersionConsistencyTest.php | 57 +
tests/WordPress7IntegrationTest.php | 2 +-
tests/bootstrap.php | 332 +-
tests/fixtures/module-registry-worker.php | 2 +
uninstall.php | 11 +-
46 files changed, 7889 insertions(+), 745 deletions(-)
create mode 100644 includes/core/class-upgrader.php
create mode 100644 includes/storage/class-data-directory.php
delete mode 100644 includes/traits/trait-has-dom-parser.php
create mode 100644 tests/AbilitiesPermissionTest.php
create mode 100644 tests/ContentFilterTest.php
create mode 100644 tests/LinkPresetCacheTest.php
create mode 100644 tests/LoginSecurityTest.php
create mode 100644 tests/SnippetsOutputTest.php
diff --git a/.distignore b/.distignore
index 8257854..db338bb 100644
--- a/.distignore
+++ b/.distignore
@@ -1,12 +1,15 @@
-# Development
-.git
-.github
-.gitignore
-.distignore
-node_modules
-vendor
-tests
-coverage
+# Development. Leading slashes anchor these to the plugin root so a bundled
+# path such as assets/vendor/ is not swept up with Composer's vendor/.
+/.git
+/.github
+/.gitignore
+/.distignore
+/node_modules
+/vendor
+/tests
+/coverage
+/src
+/docs
# Config
.editorconfig
@@ -17,11 +20,11 @@ phpcs.xml*
phpstan.neon
# Build tools
-build.sh
-package.json
-package-lock.json
-composer.json
-composer.lock
+/build.sh
+/package.json
+/package-lock.json
+/composer.json
+/composer.lock
# IDE
.idea/
@@ -45,7 +48,7 @@ CLAUDE.md
*~
# Misc
-build-release/
-.wordpress-org/
-landing-page.html
-LICENSE
+/build-release/
+/.wordpress-org/
+/landing-page.html
+/LICENSE
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 148784d..40d1d93 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -43,7 +43,27 @@ jobs:
- run: git diff --exit-code -- assets/js/wp7-admin.js assets/js/wp7-admin.asset.php assets/js/wp7-admin.css assets/js/wp7-admin-rtl.css assets/js/wp7-dataviews.css assets/js/wp7-dataviews-rtl.css
- run: composer install --no-interaction --prefer-dist
- run: composer phpcs
+
+ # The content filters run on WordPress's HTML API. Fetch just that part of
+ # core so those tests execute instead of skipping.
+ - name: Fetch the WordPress HTML API
+ run: |
+ git clone --depth=1 --filter=blob:none --sparse https://github.com/WordPress/wordpress-develop.git /tmp/wordpress
+ git -C /tmp/wordpress sparse-checkout set src/wp-includes
- run: composer test
+ env:
+ FUNCTIONALITIES_WP_DIR: /tmp/wordpress/src
+
+ - name: Translation template is current
+ run: |
+ curl -sSL -o /tmp/wp-cli.phar https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
+ php /tmp/wp-cli.phar i18n make-pot . /tmp/expected.pot \
+ --slug=functionalities --domain=functionalities \
+ --exclude=tests,src,node_modules,vendor,build,assets/vendor
+ diff \
+ <(grep -v 'POT-Creation-Date' languages/functionalities.pot) \
+ <(grep -v 'POT-Creation-Date' /tmp/expected.pot) \
+ || { echo '::error::languages/functionalities.pot is stale. Run wp i18n make-pot.'; exit 1; }
- run: |
for file in $(find assets -name '*.js' -type f); do
node --check "$file"
@@ -72,6 +92,10 @@ jobs:
functionalities/includes/core/class-module-registry.php \
functionalities/includes/core/class-wordpress-7-integration.php \
functionalities/includes/storage/class-atomic-json-store.php \
+ functionalities/includes/storage/class-data-directory.php \
+ functionalities/assets/vendor/prism/prism.min.js \
+ functionalities/assets/vendor/prism/prism.min.css \
+ functionalities/languages/functionalities.pot \
functionalities/assets/js/admin-redirects.js \
functionalities/assets/js/admin-tools.js \
functionalities/assets/js/wp7-admin.js \
@@ -82,7 +106,7 @@ jobs:
functionalities/assets/blocks/svg-icon/block.json; 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
+ if unzip -Z1 "$zip" | grep -Eq '(^|/)(tests|node_modules|src|docs|\.git|\.github)/|/vendor/(composer|bin)/|composer\.(json|lock)$|package\.json$|\.md$'; then
echo 'Development files leaked into the distribution.' >&2
exit 1
fi
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 00541d4..9c0e079 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -107,6 +107,9 @@ jobs:
"includes/admin/class-admin.php" \
"includes/admin/class-admin-ui.php" \
"includes/core/class-wordpress-7-integration.php" \
+ "includes/storage/class-data-directory.php" \
+ "assets/vendor/prism/prism.min.js" \
+ "languages/functionalities.pot" \
"assets/js/wp7-admin.js" \
"assets/js/wp7-admin.asset.php" \
"assets/js/wp7-admin.css" \
diff --git a/README.md b/README.md
index def25c5..0cc3a2d 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.5.0
+**Version:** 1.6.0
**License:** GPL-2.0-or-later
**Text Domain:** `functionalities`
**Pricing:** Free
@@ -278,6 +278,9 @@ Enhanced login protection and security measures for your WordPress site.
**Features:**
- Limit login attempts to prevent brute force attacks
+- Per-username throttling, so a distributed attempt against one account is caught
+- IP allowlist, so a shared address behind a CDN cannot lock you out of your own site
+- Unlock any address or username directly from the lockout log
- Configurable lockout durations
- Disable XML-RPC authentication and application passwords
- Hide detailed login errors to prevent user enumeration
@@ -394,52 +397,38 @@ add_filter( 'functionalities_svg_icons_sanitize', function( $svg, $slug ) {
```
functionalities/
├── assets/
-│ ├── css/
-│ │ ├── admin.css
-│ │ ├── admin-ui.css
-│ │ ├── content-regression.css
-│ │ └── svg-icons-editor.css
-│ └── js/
-│ ├── admin.js
-│ ├── admin-redirects.js
-│ ├── admin-tools.js
-│ ├── admin-ui.js
-│ ├── content-regression.js
-│ └── svg-icons-editor.js
+│ ├── blocks/svg-icon/ Block metadata for the SVG Icon block
+│ ├── css/ admin, admin-ui, content-regression, svg-icons-editor
+│ ├── js/ admin*, content-regression, svg-icons-editor, wp7-*
+│ └── vendor/prism/ Bundled Prism.js (MIT), admin syntax highlighting
├── includes/
│ ├── admin/
-│ │ ├── class-admin.php
-│ │ ├── class-admin-ui.php
-│ │ ├── class-module-controller.php
-│ │ ├── class-module-docs.php
+│ │ ├── class-admin.php Thin entry point
+│ │ ├── class-admin-ui.php Shared UI helpers
+│ │ ├── class-module-controller.php Settings + custom pages
+│ │ ├── class-module-docs.php Per-module docs text
+│ │ ├── class-settings-portability-controller.php Export / import / diagnostics
+│ │ ├── class-site-health-controller.php Scans, schedules, exposure probe
│ │ ├── 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
+│ │ ├── class-task-manager-controller.php
+│ │ ├── trait-admin-ajax.php
+│ │ ├── trait-admin-options.php
+│ │ └── trait-admin-sanitizers.php
│ ├── core/
-│ │ └── class-module-registry.php
-│ ├── features/
-│ │ ├── class-assumption-detection.php
-│ │ ├── class-block-cleanup.php
-│ │ ├── class-components.php
-│ │ ├── class-content-regression.php
-│ │ ├── class-editor-links.php
-│ │ ├── class-fonts.php
-│ │ ├── class-link-management.php
-│ │ ├── 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
+│ │ ├── class-module-registry.php Module list + lazy loader
+│ │ └── class-wordpress-7-integration.php Abilities, DataViews, AI
+│ ├── features/ One class per module (16)
+│ ├── storage/
+│ │ ├── class-atomic-json-store.php Locked, atomic JSON writes
+│ │ └── class-data-directory.php Private data path + hardening
+│ └── traits/
+│ └── trait-css-sanitizer.php
├── languages/
-├── exception-urls.json.sample
+├── src/ Source for the WordPress 7 admin bundle (not shipped)
+├── tests/ PHPUnit suite
+├── docs/ Performance baseline notes (not shipped)
+├── exception-urls-sample.json
├── functionalities.php
├── index.php
└── uninstall.php
diff --git a/ROADMAP.md b/ROADMAP.md
index e43cdc0..e80473b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,10 +1,36 @@
# Dynamic Functionalities Roadmap
-Version **1.5.0** builds on the completed v1.4.8 reliability work with
-progressive WordPress 7 platform integrations. The release is tagged only after
-automated checks and the real-WordPress smoke gate pass.
+Version **1.6.0** is a security and correctness release built on the v1.5.0 WordPress 7 work. The
+release is tagged only after automated checks and the real-WordPress smoke gate pass.
-## v1.5.0: WordPress 7 platform integration
+## v1.6.0: security, storage, and the HTML API
+
+- Give every Abilities API operation its own permission callback and a strict input schema.
+- Move redirect, 404, and task data into a private directory, migrate existing files, and add a Site
+ Health probe that confirms over HTTP that it is unreachable.
+- Filter header and footer snippets at save time only, so logged-out visitors receive them intact.
+- Cache a remote JSON exception preset and invalidate it when a post or page is edited.
+- Replace DOMDocument with the WordPress HTML API in Link Management, Block Cleanup, and Schema, and
+ delete the JS-framework skip guard it required.
+- Buffer redirect hits and 404 aggregates instead of rewriting JSON per request; run redirects at
+ `parse_request`.
+- Cache the Content Integrity list column, and keep the SVG icon library out of the autoload set.
+- Harden the service worker: no admin, login, REST, cross-origin, or private responses; capped runtime
+ cache; per-URL precache.
+- Add username throttling, an IP allowlist, and an unlock action to Login Security.
+- Bundle Prism.js instead of loading it from a CDN.
+- Generate a real translation template and stop shipping build sources in the zip.
+
+## v1.6.0 release gate
+
+- No ability can be reached without the capability its operation implies.
+- Plugin data files return a non-200 to an anonymous request on Apache, nginx, and IIS.
+- A snippet containing `&&` renders byte-identical for an anonymous visitor and an administrator.
+- A page using Vue or Alpine passes through all three content filters unchanged.
+- Existing option names, hooks, admin URLs, shortcodes, and stored JSON remain compatible, and data
+ written by earlier versions is migrated on first load.
+
+## v1.5.0: WordPress 7 platform integration (completed)
- Upgrade the SVG Icon block to Block API v3.
- Add a WordPress Core Icon source without giving up the existing custom icon
diff --git a/assets/blocks/svg-icon/block.json b/assets/blocks/svg-icon/block.json
index e7cfd2e..853c76c 100644
--- a/assets/blocks/svg-icon/block.json
+++ b/assets/blocks/svg-icon/block.json
@@ -2,7 +2,7 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "functionalities/svg-icon-block",
- "version": "1.5.0",
+ "version": "1.6.0",
"title": "SVG Icon",
"category": "design",
"icon": "flag",
diff --git a/build.sh b/build.sh
index 1690f6c..2fa6b30 100755
--- a/build.sh
+++ b/build.sh
@@ -29,26 +29,31 @@ rm -rf "${BUILD_DIR}"
rm -f "${ZIP_NAME}"
mkdir -p "${BUILD_DIR}/${PLUGIN_SLUG}"
-# Files and directories to include
-INCLUDE=(
- "assets"
- "includes"
- "languages"
+# Assemble with the same exclude list the release workflow uses, so a local
+# build and a tagged release cannot drift apart.
+echo "Copying files (excluding .distignore entries)..."
+rsync -a \
+ --exclude-from=".distignore" \
+ --exclude="${BUILD_DIR}" \
+ . "${BUILD_DIR}/${PLUGIN_SLUG}/"
+
+# Fail loudly if something the plugin needs at runtime did not make it in.
+REQUIRED=(
"functionalities.php"
"index.php"
"readme.txt"
"uninstall.php"
- "exception-urls-sample.json"
+ "includes/core/class-module-registry.php"
+ "includes/core/class-wordpress-7-integration.php"
+ "includes/storage/class-data-directory.php"
+ "assets/js/wp7-admin.js"
+ "assets/vendor/prism/prism.min.js"
+ "assets/blocks/svg-icon/block.json"
)
-
-# Copy files to build directory
-echo "Copying files..."
-for item in "${INCLUDE[@]}"; do
- if [ -e "$item" ]; then
- cp -r "$item" "${BUILD_DIR}/${PLUGIN_SLUG}/"
- echo " + $item"
- else
- echo " - $item (not found, skipping)"
+for required in "${REQUIRED[@]}"; do
+ if [ ! -f "${BUILD_DIR}/${PLUGIN_SLUG}/${required}" ]; then
+ echo " ERROR: missing ${required}" >&2
+ exit 1
fi
done
diff --git a/functionalities.php b/functionalities.php
index ae88bf1..414b5cc 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.5.0
+ * Version: 1.6.0
* Author: Gaurav Tiwari
* Author URI: https://gauravtiwari.org
* License: GPL-2.0-or-later
@@ -19,7 +19,7 @@
// Define constants.
if ( ! defined( 'FUNCTIONALITIES_VERSION' ) ) {
- define( 'FUNCTIONALITIES_VERSION', '1.5.0' );
+ define( 'FUNCTIONALITIES_VERSION', '1.6.0' );
}
if ( ! defined( 'FUNCTIONALITIES_FILE' ) ) {
define( 'FUNCTIONALITIES_FILE', __FILE__ );
@@ -65,6 +65,7 @@ function ( string $class ) {
function () {
\Functionalities\Core\WordPress_7_Integration::init();
if ( \is_admin() ) {
+ \Functionalities\Core\Upgrader::maybe_upgrade();
\Functionalities\Admin\Admin::init();
}
\Functionalities\Admin\Site_Health_Controller::init();
@@ -117,6 +118,7 @@ function ( array $links, string $file ): array {
__FILE__,
function () {
\wp_clear_scheduled_hook( 'functionalities_assumption_background_scan' );
+ \wp_clear_scheduled_hook( 'functionalities_redirect_flush_buffer' );
if ( function_exists( 'flush_rewrite_rules' ) ) {
\flush_rewrite_rules();
}
diff --git a/includes/admin/class-module-controller.php b/includes/admin/class-module-controller.php
index 663c1fc..5910a7d 100644
--- a/includes/admin/class-module-controller.php
+++ b/includes/admin/class-module-controller.php
@@ -1222,9 +1222,22 @@ function () {
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( 'limit_revisions', \__( 'Limit post revisions', 'functionalities' ) );
+ \add_settings_field(
+ 'revisions_limit',
+ \__( 'Revisions to keep', 'functionalities' ),
+ function () {
+ $opts = self::get_misc_options();
+ $val = isset( $opts['revisions_limit'] ) ? (int) $opts['revisions_limit'] : 10;
+ echo ' ';
+ echo \esc_html__( 'revisions per post. Applies when the option above is enabled. Zero keeps none.', 'functionalities' );
+ },
+ 'functionalities_misc',
+ 'functionalities_misc_section'
+ );
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_heartbeat', \__( 'Disable Heartbeat API on the frontend', 'functionalities' ) );
+ self::add_misc_field( 'disable_heartbeat_admin', \__( 'Also disable Heartbeat in wp-admin (breaks autosave and post locking)', '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' ) );
@@ -1336,16 +1349,33 @@ function () {
echo '' . \esc_html__( 'Customize the login page with your logo and colors', 'functionalities' ) . ' ';
echo '';
echo '';
+ if ( \Functionalities\Features\Login_Security::lockouts_share_one_ip() ) {
+ echo '';
+ echo '' . \esc_html__( 'Every recent lockout came from the same address.', 'functionalities' ) . ' ';
+ echo \esc_html__( 'That usually means this site sits behind a proxy or CDN and all visitors share one address, so an IP lockout blocks everyone. Enable "Trust Proxy Headers" if the proxy is trusted, and add your own address to the allowlist below.', 'functionalities' );
+ echo '
';
+ }
+
$logs = \Functionalities\Features\Login_Security::get_lockout_log( 5 );
if ( ! empty( $logs ) ) {
+ $unlock_nonce = \wp_create_nonce( 'functionalities_login_unlock' );
echo '';
echo '
' . \esc_html__( 'Recent Lockouts', 'functionalities' ) . ' ';
- echo '
';
+ echo '';
foreach ( $logs as $log ) {
- echo '' . \esc_html( $log['ip'] ) . ' — ' . \esc_html( $log['username'] ) . ' (' . \esc_html( $log['time'] ) . ') ';
+ echo '';
+ echo '' . \esc_html( $log['ip'] ) . ' — ' . \esc_html( $log['username'] ) . ' (' . \esc_html( $log['time'] ) . ') ';
+ echo '' . \esc_html__( 'Unlock', 'functionalities' ) . ' ';
+ echo ' ';
}
echo ' ';
echo ' ';
+ printf(
+ '',
+ \wp_json_encode( $unlock_nonce ),
+ \wp_json_encode( \__( 'Unlocked', 'functionalities' ) ),
+ \wp_json_encode( \__( 'Failed', 'functionalities' ) )
+ );
}
},
'functionalities_login_security'
@@ -1431,6 +1461,28 @@ function () {
'functionalities_login_security',
'functionalities_login_security_section'
);
+ \add_settings_field(
+ 'lock_usernames',
+ \__( 'Throttle by Username', 'functionalities' ),
+ function () {
+ $o = self::get_login_security_options();
+ echo ' ' . \esc_html__( 'Also lock a username after repeated failures from any address', 'functionalities' ) . ' ';
+ echo '' . \esc_html__( 'Catches distributed attempts against one account. Triggers at twice the attempt limit above.', 'functionalities' ) . '
';
+ },
+ 'functionalities_login_security',
+ 'functionalities_login_security_section'
+ );
+ \add_settings_field(
+ 'allowlist_ips',
+ \__( 'Never Lock These IPs', 'functionalities' ),
+ function () {
+ $o = self::get_login_security_options();
+ echo '';
+ echo '' . \esc_html__( 'One per line. A trailing * matches a prefix. Add your own address so a lockout cannot shut you out.', 'functionalities' ) . '
';
+ },
+ 'functionalities_login_security',
+ 'functionalities_login_security_section'
+ );
\add_settings_field(
'custom_logo_url',
\__( 'Custom Logo URL', 'functionalities' ),
@@ -3224,28 +3276,51 @@ public static function field_database_update_tool(): void {
$btn.prop('disabled', true).text('');
$result.html('');
- $.ajax({
- url: ajaxurl,
- type: 'POST',
- data: {
- action: 'functionalities_update_database',
- url: url,
- nonce: ''
- },
- success: function(response) {
- if (response.success) {
- $result.html('' + response.data.message + '
');
- } else {
- $result.html('' + response.data.message + '
');
+ var totalUpdated = 0;
+ var totalScanned = 0;
+ var nonce = '';
+
+ function finish(html, cls) {
+ $result.html('');
+ $btn.prop('disabled', false).text('');
+ }
+
+ // Walk the whole table with an ID cursor. Each batch reports
+ // progress so a large site shows movement instead of stalling.
+ function runBatch(after) {
+ $.ajax({
+ url: ajaxurl,
+ type: 'POST',
+ data: {
+ action: 'functionalities_update_database',
+ url: url,
+ after: after,
+ nonce: nonce
+ },
+ success: function(response) {
+ if (!response.success) {
+ finish(response.data.message, 'error');
+ return;
+ }
+
+ totalUpdated += parseInt(response.data.count, 10) || 0;
+ totalScanned += parseInt(response.data.processed, 10) || 0;
+
+ if (response.data.has_more) {
+ $result.html('' + response.data.message + ' (' + totalUpdated + '/' + totalScanned + ')
');
+ runBatch(parseInt(response.data.last_id, 10) || 0);
+ return;
+ }
+
+ finish(' ' + totalUpdated + ' / ' + totalScanned, 'success');
+ },
+ error: function() {
+ finish('', 'error');
}
- },
- error: function() {
- $result.html('');
- },
- complete: function() {
- $btn.prop('disabled', false).text('');
- }
- });
+ });
+ }
+
+ runBatch(0);
});
});
@@ -5247,8 +5322,8 @@ private static function render_task_manager_overview( array $projects, string $n
' . \esc_html( WP_CONTENT_DIR . '/functionalities/tasks/' ) . ''
+ \esc_html__( 'Tasks are stored in a private folder under %s that is not reachable over the web.', 'functionalities' ),
+ '' . \esc_html( str_replace( ABSPATH, '', \Functionalities\Storage\Data_Directory::base() ) ) . '/'
);
?>
@@ -6509,7 +6584,7 @@ public static function render_module_svg_icons( array $module ): void {
// Handle enable/disable toggle.
if ( isset( $_POST['functionalities_svg_icons_toggle'] ) && \wp_verify_nonce( \sanitize_text_field( \wp_unslash( $_POST['_wpnonce'] ?? '' ) ), 'functionalities_svg_icons_toggle' ) ) {
$opts['enabled'] = ! empty( $_POST['enabled'] );
- \update_option( 'functionalities_svg_icons', $opts );
+ \update_option( 'functionalities_svg_icons', $opts, false );
echo '' . \esc_html__( 'Settings saved.', 'functionalities' ) . '
';
}
?>
diff --git a/includes/admin/class-module-docs.php b/includes/admin/class-module-docs.php
index 4a012e4..a98b720 100644
--- a/includes/admin/class-module-docs.php
+++ b/includes/admin/class-module-docs.php
@@ -45,16 +45,24 @@ public static function get_all(): array {
),
'hooks' => array(
array(
- 'name' => 'functionalities_nofollow_exceptions',
- 'description' => \__( 'Modify exception list', 'functionalities' ),
+ 'name' => 'functionalities_exception_domains',
+ 'description' => \__( 'Modify the exception domain list', 'functionalities' ),
),
array(
- 'name' => 'functionalities_link_attributes',
- 'description' => \__( 'Modify link attributes', 'functionalities' ),
+ 'name' => 'functionalities_exception_urls',
+ 'description' => \__( 'Modify the exception URL list', 'functionalities' ),
),
array(
- 'name' => 'functionalities_process_links',
- 'description' => \__( 'Toggle link processing', 'functionalities' ),
+ 'name' => 'functionalities_json_preset_path',
+ 'description' => \__( 'Override the JSON preset file path or URL', 'functionalities' ),
+ ),
+ array(
+ 'name' => 'functionalities_link_preset_ttl',
+ 'description' => \__( 'Change how long a resolved JSON preset stays cached', 'functionalities' ),
+ ),
+ array(
+ 'name' => 'functionalities_link_update_batch_limit',
+ 'description' => \__( 'Change the bulk database update batch size', 'functionalities' ),
),
),
),
@@ -74,6 +82,10 @@ public static function get_all(): array {
'name' => 'functionalities_block_cleanup_classes',
'description' => \__( 'Modify classes to remove', 'functionalities' ),
),
+ array(
+ 'name' => 'functionalities_block_cleanup_content',
+ 'description' => \__( 'Modify content after classes are removed', 'functionalities' ),
+ ),
),
),
@@ -138,17 +150,21 @@ public static function get_all(): array {
'description' => \__( 'Toggle all schema output', 'functionalities' ),
),
array(
- 'name' => 'functionalities_schema_site_type',
+ 'name' => 'functionalities_schema_site_itemtype',
'description' => \__( 'Modify site itemtype', 'functionalities' ),
),
array(
- 'name' => 'functionalities_schema_article_type',
+ 'name' => 'functionalities_schema_article_itemtype',
'description' => \__( 'Modify article itemtype', 'functionalities' ),
),
array(
- 'name' => 'functionalities_schema_content',
+ 'name' => 'functionalities_schema_article_content',
'description' => \__( 'Modify wrapped content', 'functionalities' ),
),
+ array(
+ 'name' => 'functionalities_schema_language_attributes',
+ 'description' => \__( 'Modify the html element attributes', 'functionalities' ),
+ ),
),
),
@@ -244,16 +260,16 @@ public static function get_all(): array {
'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' ),
+ 'name' => 'functionalities_misc_option_{$key}',
+ 'description' => \__( 'Control any single toggle, for example functionalities_misc_option_disable_emojis', 'functionalities' ),
),
array(
- 'name' => 'functionalities_misc_disable_emojis',
- 'description' => \__( 'Control emoji removal', 'functionalities' ),
+ 'name' => 'functionalities_misc_init',
+ 'description' => \__( 'Action: fires after all toggles are processed', 'functionalities' ),
),
array(
- 'name' => 'functionalities_misc_disable_embeds',
- 'description' => \__( 'Control embed removal', 'functionalities' ),
+ 'name' => 'functionalities_misc_feeds_disabled_message',
+ 'description' => \__( 'Change the disabled-feeds message', 'functionalities' ),
),
),
),
@@ -267,16 +283,16 @@ public static function get_all(): array {
),
'hooks' => array(
array(
- 'name' => 'functionalities_meta_enabled',
- 'description' => \__( 'Toggle module', 'functionalities' ),
+ 'name' => 'functionalities_meta_licenses',
+ 'description' => \__( 'Add or modify available licenses', 'functionalities' ),
),
array(
- 'name' => 'functionalities_meta_copyright_holder',
- 'description' => \__( 'Modify copyright holder', 'functionalities' ),
+ 'name' => 'functionalities_meta_standalone_schema',
+ 'description' => \__( 'Modify the standalone JSON-LD output', 'functionalities' ),
),
array(
- 'name' => 'functionalities_meta_license',
- 'description' => \__( 'Modify license output', 'functionalities' ),
+ 'name' => 'functionalities_module_enabled',
+ 'description' => \__( 'Toggle any module, receives the module slug', 'functionalities' ),
),
),
),
@@ -291,20 +307,20 @@ public static function get_all(): array {
'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' ),
+ 'name' => 'functionalities_content_regression_post_types',
+ 'description' => \__( 'Modify the monitored post types', 'functionalities' ),
),
array(
- 'name' => 'functionalities_regression_post_types',
- 'description' => \__( 'Modify enabled post types', 'functionalities' ),
+ 'name' => 'functionalities_content_regression_warnings',
+ 'description' => \__( 'Modify detected warnings for a post', 'functionalities' ),
),
array(
- 'name' => 'functionalities_regression_warnings',
- 'description' => \__( 'Modify detected warnings', 'functionalities' ),
+ 'name' => 'functionalities_content_regression_snapshot',
+ 'description' => \__( 'Modify a snapshot before it is stored', 'functionalities' ),
),
array(
- 'name' => 'functionalities_regression_snapshot_saved',
- 'description' => \__( 'Action: after snapshot saved', 'functionalities' ),
+ 'name' => 'functionalities_content_regression_snapshot_saved',
+ 'description' => \__( 'Action: after a snapshot is saved', 'functionalities' ),
),
),
),
@@ -319,16 +335,12 @@ public static function get_all(): array {
'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' ),
+ 'name' => 'functionalities_assumption_detection_warnings',
+ 'description' => \__( 'Modify detected findings before they are stored', 'functionalities' ),
),
array(
- 'name' => 'functionalities_assumptions_warnings',
- 'description' => \__( 'Modify detected warnings', 'functionalities' ),
+ 'name' => 'functionalities_assumption_detection_enabled',
+ 'description' => \__( 'Toggle individual detectors, receives the detector key', 'functionalities' ),
),
),
),
diff --git a/includes/admin/class-settings-portability-controller.php b/includes/admin/class-settings-portability-controller.php
index 905b4a3..e84fa1c 100644
--- a/includes/admin/class-settings-portability-controller.php
+++ b/includes/admin/class-settings-portability-controller.php
@@ -345,7 +345,9 @@ 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' ) );
+ // Only the three snippet arrays hold custom code. The GA4 toggle and
+ // measurement ID are ordinary settings and travel with an export.
+ $fields = array( 'header', 'body_open', 'footer' );
} elseif ( 'components' === $slug || 'svg-icons' === $slug ) {
$fields = array( 'items' );
if ( 'svg-icons' === $slug ) {
diff --git a/includes/admin/class-site-health-controller.php b/includes/admin/class-site-health-controller.php
index f4067dd..9472719 100644
--- a/includes/admin/class-site-health-controller.php
+++ b/includes/admin/class-site-health-controller.php
@@ -38,13 +38,70 @@ public static function init(): void {
* @return array
*/
public static function register_site_health_test( array $tests ): array {
- $tests['direct']['functionalities_assumptions'] = array(
+ $tests['direct']['functionalities_assumptions'] = array(
'label' => \__( 'Functionalities assumption scan', 'functionalities' ),
'test' => array( __CLASS__, 'get_site_health_result' ),
);
+ $tests['async']['functionalities_data_exposure'] = array(
+ 'label' => \__( 'Functionalities data files are private', 'functionalities' ),
+ 'test' => 'functionalities_data_exposure',
+ 'has_rest' => true,
+ 'async_direct_test' => array( __CLASS__, 'get_data_exposure_result' ),
+ );
return $tests;
}
+ /**
+ * Confirm the private data directory is not served over HTTP.
+ *
+ * Redirects, the 404 log, and task notes are JSON files under wp-content.
+ * Server rules are written next to them, but nginx and Caddy ignore
+ * .htaccess, so the only way to know is to ask the server.
+ *
+ * @since 1.6.0
+ * @return array
+ */
+ public static function get_data_exposure_result(): array {
+ $result = array(
+ 'label' => \__( 'Functionalities data files are not publicly readable', 'functionalities' ),
+ 'status' => 'good',
+ 'badge' => array(
+ 'label' => \__( 'Functionalities', 'functionalities' ),
+ 'color' => 'blue',
+ ),
+ 'description' => '' . \esc_html__( 'Redirects, the 404 log, and task notes are stored in a private folder that the web server does not serve.', 'functionalities' ) . '
',
+ 'actions' => '',
+ 'test' => 'functionalities_data_exposure',
+ );
+
+ $response = \wp_remote_get(
+ \Functionalities\Storage\Data_Directory::probe_url(),
+ array(
+ 'timeout' => 5,
+ 'sslverify' => false,
+ )
+ );
+
+ if ( \is_wp_error( $response ) ) {
+ $result['status'] = 'recommended';
+ $result['label'] = \__( 'The privacy of Functionalities data files could not be checked', 'functionalities' );
+ $result['description'] = '' . \esc_html__( 'A loopback request to the data folder failed, so this check could not complete. This is usually a loopback restriction rather than a problem with the files themselves.', 'functionalities' ) . '
';
+ return $result;
+ }
+
+ if ( 200 === (int) \wp_remote_retrieve_response_code( $response ) ) {
+ // The folder name carries 20 random characters and directory listing
+ // is blocked, so the path is not discoverable — this is a defence in
+ // depth gap, not an open door. Saying "critical" here would cry wolf
+ // on every nginx and Caddy site, which ignore .htaccess by design.
+ $result['status'] = 'recommended';
+ $result['label'] = \__( 'Functionalities data files would be served if their folder name were known', 'functionalities' );
+ $result['description'] = '' . \esc_html__( 'The plugin stores redirects, the 404 log, and task notes in a folder with a random name, and the server does serve files from it. The name is not guessable and the folder cannot be listed, so this is not an open door. For defence in depth, add a rule to your server configuration denying access to the plugin data folder under wp-content. The bundled .htaccess and web.config already do this on Apache and IIS; nginx and Caddy ignore them.', 'functionalities' ) . '
';
+ }
+
+ return $result;
+ }
+
/**
* Build a current Site Health result.
*
diff --git a/includes/admin/trait-admin-ajax.php b/includes/admin/trait-admin-ajax.php
index be77729..c812f5a 100644
--- a/includes/admin/trait-admin-ajax.php
+++ b/includes/admin/trait-admin-ajax.php
@@ -37,11 +37,12 @@ public static function ajax_update_database(): void {
return;
}
- // Get URL from request.
- $url = isset( $_POST['url'] ) ? \sanitize_text_field( wp_unslash( $_POST['url'] ) ) : '';
+ // Get URL and paging cursor from request.
+ $url = isset( $_POST['url'] ) ? \sanitize_text_field( wp_unslash( $_POST['url'] ) ) : '';
+ $after = isset( $_POST['after'] ) ? absint( wp_unslash( $_POST['after'] ) ) : 0;
// Call the update method.
- $result = \Functionalities\Features\Link_Management::update_links_in_database( $url );
+ $result = \Functionalities\Features\Link_Management::update_links_in_database( $url, $after );
if ( $result['success'] ) {
\wp_send_json_success( $result );
diff --git a/includes/admin/trait-admin-sanitizers.php b/includes/admin/trait-admin-sanitizers.php
index 90ae07e..8a79208 100644
--- a/includes/admin/trait-admin-sanitizers.php
+++ b/includes/admin/trait-admin-sanitizers.php
@@ -153,6 +153,7 @@ public static function sanitize_snippets( $input ): array {
}
$allowed_tags = \Functionalities\Features\Snippets::snippet_allowed_tags();
+ $unfiltered = \current_user_can( 'unfiltered_html' );
foreach ( array( 'header', 'body_open', 'footer' ) as $location ) {
if ( empty( $input[ $location ] ) || ! \is_array( $input[ $location ] ) ) {
@@ -164,14 +165,18 @@ public static function sanitize_snippets( $input ): array {
continue;
}
- if ( ! \current_user_can( 'unfiltered_html' ) ) {
+ // Filter once, here, against the capability of the user saving
+ // the snippet. Output is verbatim, so a snippet containing JS
+ // operators such as && survives to the front end intact.
+ if ( ! $unfiltered ) {
$code = \Functionalities\Features\Snippets::kses_with_styles( $code, $allowed_tags );
}
$out[ $location ][] = array(
- 'label' => \sanitize_text_field( $item['label'] ?? '' ),
- 'code' => $code,
- 'enabled' => ! empty( $item['enabled'] ),
+ 'label' => \sanitize_text_field( $item['label'] ?? '' ),
+ 'code' => $code,
+ 'enabled' => ! empty( $item['enabled'] ),
+ 'unfiltered' => $unfiltered,
);
}
}
@@ -257,6 +262,7 @@ public static function sanitize_misc( $input ): array {
'limit_revisions',
'disable_dashicons_for_guests',
'disable_heartbeat',
+ 'disable_heartbeat_admin',
'disable_admin_bar_front',
'remove_jquery_migrate',
'enable_prism_admin',
@@ -266,6 +272,9 @@ public static function sanitize_misc( $input ): array {
foreach ( $keys as $k ) {
$out[ $k ] = ! empty( $input[ $k ] );
}
+
+ $out['revisions_limit'] = max( 0, min( 100, (int) ( $input['revisions_limit'] ?? 10 ) ) );
+
return $out;
}
@@ -424,6 +433,8 @@ public static function sanitize_login_security( $input ): array {
'disable_application_passwords' => ! empty( $input['disable_application_passwords'] ),
'hide_login_errors' => ! empty( $input['hide_login_errors'] ),
'trust_proxy_headers' => ! empty( $input['trust_proxy_headers'] ),
+ 'lock_usernames' => ! empty( $input['lock_usernames'] ),
+ 'allowlist_ips' => \sanitize_textarea_field( $input['allowlist_ips'] ?? '' ),
'custom_logo_url' => \esc_url_raw( $input['custom_logo_url'] ?? '' ),
'custom_background_color' => \sanitize_hex_color( $input['custom_background_color'] ?? '' ) ?: '',
'custom_form_background' => \sanitize_hex_color( $input['custom_form_background'] ?? '' ) ?: '',
diff --git a/includes/core/class-upgrader.php b/includes/core/class-upgrader.php
new file mode 100644
index 0000000..70e42b3
--- /dev/null
+++ b/includes/core/class-upgrader.php
@@ -0,0 +1,98 @@
+ 'object',
'additionalProperties' => array( 'type' => 'object' ),
),
- true
+ true,
+ true,
+ array( __CLASS__, 'permission_manage_options' )
);
self::register_ability(
@@ -88,7 +90,9 @@ public static function register_abilities(): void {
null,
array( __CLASS__, 'ability_run_diagnostics' ),
array( 'type' => 'object' ),
- true
+ true,
+ true,
+ array( __CLASS__, 'permission_manage_options' )
);
self::register_ability(
@@ -99,7 +103,8 @@ public static function register_abilities(): void {
array( __CLASS__, 'ability_scan_assumptions' ),
array( 'type' => 'array' ),
false,
- false
+ false,
+ array( __CLASS__, 'permission_manage_options' )
);
self::register_ability(
@@ -107,9 +112,10 @@ public static function register_abilities(): void {
\__( 'Check content integrity', 'functionalities' ),
\__( 'Checks one post against its Content Integrity baseline.', 'functionalities' ),
array(
- 'type' => 'object',
- 'required' => array( 'post_id' ),
- 'properties' => array(
+ 'type' => 'object',
+ 'required' => array( 'post_id' ),
+ 'additionalProperties' => false,
+ 'properties' => array(
'post_id' => array(
'type' => 'integer',
'minimum' => 1,
@@ -118,7 +124,9 @@ public static function register_abilities(): void {
),
array( __CLASS__, 'ability_check_content_integrity' ),
array( 'type' => 'array' ),
- true
+ true,
+ true,
+ array( __CLASS__, 'permission_edit_monitored_post' )
);
self::register_ability(
@@ -126,9 +134,10 @@ public static function register_abilities(): void {
\__( 'Preview redirect import', 'functionalities' ),
\__( 'Validates CSV redirect data and reports duplicates, loops, chains, and invalid rows without saving.', 'functionalities' ),
array(
- 'type' => 'object',
- 'required' => array( 'csv' ),
- 'properties' => array(
+ 'type' => 'object',
+ 'required' => array( 'csv' ),
+ 'additionalProperties' => false,
+ 'properties' => array(
'csv' => array(
'type' => 'string',
'maxLength' => 1000000,
@@ -137,7 +146,9 @@ public static function register_abilities(): void {
),
array( __CLASS__, 'ability_preview_redirect_import' ),
array( 'type' => 'object' ),
- true
+ true,
+ true,
+ array( __CLASS__, 'permission_manage_options' )
);
self::register_ability(
@@ -145,9 +156,10 @@ public static function register_abilities(): void {
\__( 'Create redirect', 'functionalities' ),
\__( 'Creates a validated redirect in the Redirect Manager.', 'functionalities' ),
array(
- 'type' => 'object',
- 'required' => array( 'from_url', 'to_url' ),
- 'properties' => array(
+ 'type' => 'object',
+ 'required' => array( 'from_url', 'to_url' ),
+ 'additionalProperties' => false,
+ 'properties' => array(
'from_url' => array(
'type' => 'string',
'minLength' => 1,
@@ -168,7 +180,8 @@ public static function register_abilities(): void {
array( __CLASS__, 'ability_create_redirect' ),
array( 'type' => 'object' ),
false,
- false
+ false,
+ array( __CLASS__, 'permission_manage_options' )
);
self::register_ability(
@@ -176,9 +189,10 @@ public static function register_abilities(): void {
\__( 'Create task', 'functionalities' ),
\__( 'Adds a task to an existing Task Manager project.', 'functionalities' ),
array(
- 'type' => 'object',
- 'required' => array( 'project', 'text' ),
- 'properties' => array(
+ 'type' => 'object',
+ 'required' => array( 'project', 'text' ),
+ 'additionalProperties' => false,
+ 'properties' => array(
'project' => array(
'type' => 'string',
'pattern' => '^[a-zA-Z0-9_-]+$',
@@ -199,7 +213,8 @@ public static function register_abilities(): void {
array( __CLASS__, 'ability_create_task' ),
array( 'type' => 'object' ),
false,
- false
+ false,
+ array( __CLASS__, 'permission_manage_options' )
);
self::register_ability(
@@ -207,11 +222,13 @@ public static function register_abilities(): void {
\__( 'Toggle module', 'functionalities' ),
\__( 'Enables or disables one Functionalities module while preserving its settings.', 'functionalities' ),
array(
- 'type' => 'object',
- 'required' => array( 'module', 'enabled' ),
- 'properties' => array(
+ 'type' => 'object',
+ 'required' => array( 'module', 'enabled' ),
+ 'additionalProperties' => false,
+ 'properties' => array(
'module' => array(
- 'type' => 'string',
+ 'type' => 'string',
+ 'maxLength' => 64,
),
'enabled' => array(
'type' => 'boolean',
@@ -221,7 +238,8 @@ public static function register_abilities(): void {
array( __CLASS__, 'ability_toggle_module' ),
array( 'type' => 'object' ),
false,
- true
+ true,
+ array( __CLASS__, 'permission_manage_options' )
);
self::register_ability(
@@ -229,9 +247,10 @@ public static function register_abilities(): void {
\__( 'Explain a finding with AI', 'functionalities' ),
\__( 'Uses the WordPress AI Client to explain one Assumption Detection or Content Integrity finding. This requires explicit opt-in and a configured provider.', 'functionalities' ),
array(
- 'type' => 'object',
- 'required' => array( 'context', 'finding' ),
- 'properties' => array(
+ 'type' => 'object',
+ 'required' => array( 'context', 'finding' ),
+ 'additionalProperties' => false,
+ 'properties' => array(
'context' => array(
'type' => 'string',
'enum' => array( 'assumption', 'content-integrity' ),
@@ -251,7 +270,8 @@ public static function register_abilities(): void {
),
),
false,
- false
+ false,
+ array( __CLASS__, 'permission_manage_options' )
);
}
@@ -266,15 +286,16 @@ public static function register_abilities(): void {
* @param array $output Output schema.
* @param bool $is_readonly Whether execution is read-only.
* @param bool $idempotent Whether repeated execution is idempotent.
+ * @param callable $permission Permission callback for this ability.
* @return void
*/
- private static function register_ability( string $name, string $label, string $description, ?array $input, callable $callback, array $output, bool $is_readonly, bool $idempotent = true ): void {
+ private static function register_ability( string $name, string $label, string $description, ?array $input, callable $callback, array $output, bool $is_readonly, bool $idempotent, callable $permission ): void {
$args = array(
'label' => $label,
'description' => $description,
'category' => 'functionalities',
'execute_callback' => $callback,
- 'permission_callback' => array( __CLASS__, 'ability_permission' ),
+ 'permission_callback' => $permission,
'output_schema' => $output,
'meta' => array(
'show_in_rest' => true,
@@ -292,16 +313,52 @@ private static function register_ability( string $name, string $label, string $d
}
/**
- * Check ability permissions.
+ * Require site administration rights.
*
+ * Site-wide abilities never widen their permission based on ability input.
+ * A previous shared callback downgraded to `edit_post` whenever the input
+ * carried a `post_id`, which let any contributor with one draft reach the
+ * administrator-only operations by adding that property to the request.
+ *
+ * @since 1.6.0
+ * @return bool
+ */
+ public static function permission_manage_options(): bool {
+ return \current_user_can( 'manage_options' );
+ }
+
+ /**
+ * Require edit rights on one post that Content Integrity actually monitors.
+ *
+ * @since 1.6.0
* @param mixed $input Ability input.
* @return bool
*/
- public static function ability_permission( $input = null ): bool {
- if ( is_array( $input ) && isset( $input['post_id'] ) ) {
- return \current_user_can( 'edit_post', (int) $input['post_id'] );
+ public static function permission_edit_monitored_post( $input = null ): bool {
+ if ( ! is_array( $input ) || ! isset( $input['post_id'] ) ) {
+ return false;
}
- return \current_user_can( 'manage_options' );
+
+ $post_id = (int) $input['post_id'];
+ if ( $post_id < 1 ) {
+ return false;
+ }
+
+ $post = \get_post( $post_id );
+ if ( ! $post instanceof \WP_Post ) {
+ return false;
+ }
+
+ $options = (array) \get_option( 'functionalities_content_regression', array() );
+ $post_types = isset( $options['post_types'] ) && is_array( $options['post_types'] )
+ ? $options['post_types']
+ : array( 'post', 'page' );
+
+ if ( ! in_array( $post->post_type, $post_types, true ) ) {
+ return false;
+ }
+
+ return \current_user_can( 'edit_post', $post_id );
}
/**
diff --git a/includes/features/class-assumption-detection.php b/includes/features/class-assumption-detection.php
index b0eb05d..8becc3a 100644
--- a/includes/features/class-assumption-detection.php
+++ b/includes/features/class-assumption-detection.php
@@ -159,65 +159,45 @@ public static function run_detection(): void {
// Clear the trigger.
\delete_transient( 'functionalities_run_assumption_detection' );
- // Run detectors.
- if ( ! empty( $opts['detect_schema_collision'] ) ) {
- $schema_warnings = self::detect_schema_collisions();
- $warnings = array_merge( $warnings, $schema_warnings );
- }
-
- if ( ! empty( $opts['detect_analytics_dupe'] ) ) {
- $analytics_warnings = self::detect_analytics_duplication();
- $warnings = array_merge( $warnings, $analytics_warnings );
- }
-
- if ( ! empty( $opts['detect_font_redundancy'] ) ) {
- $font_warnings = self::detect_font_redundancy();
- $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 );
- }
-
- if ( ! empty( $opts['detect_jquery_conflicts'] ) ) {
- $jquery_warnings = self::detect_jquery_conflicts();
- $warnings = array_merge( $warnings, $jquery_warnings );
- }
-
- if ( ! empty( $opts['detect_meta_duplication'] ) ) {
- $meta_warnings = self::detect_meta_duplication();
- $warnings = array_merge( $warnings, $meta_warnings );
- }
-
- if ( ! empty( $opts['detect_rest_exposure'] ) ) {
- $rest_warnings = self::detect_rest_exposure();
- $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 );
- }
-
- if ( ! empty( $opts['detect_mixed_content'] ) ) {
- $mixed_warnings = self::detect_mixed_content();
- $warnings = array_merge( $warnings, $mixed_warnings );
- }
+ // Run detectors. Each is keyed by its option so a single filter can turn
+ // one on or off without touching stored settings.
+ $detectors = array(
+ 'detect_schema_collision' => 'detect_schema_collisions',
+ 'detect_analytics_dupe' => 'detect_analytics_duplication',
+ 'detect_font_redundancy' => 'detect_font_redundancy',
+ 'detect_inline_css_growth' => 'detect_inline_css_growth',
+ 'detect_jquery_conflicts' => 'detect_jquery_conflicts',
+ 'detect_meta_duplication' => 'detect_meta_duplication',
+ 'detect_rest_exposure' => 'detect_rest_exposure',
+ 'detect_lazy_load_conflict' => 'detect_lazy_load_conflicts',
+ 'detect_mixed_content' => 'detect_mixed_content',
+ 'detect_missing_security_headers' => 'detect_missing_security_headers',
+ 'detect_debug_exposure' => 'detect_debug_exposure',
+ 'detect_cron_issues' => 'detect_cron_issues',
+ );
- if ( ! empty( $opts['detect_missing_security_headers'] ) ) {
- $header_warnings = self::detect_missing_security_headers();
- $warnings = array_merge( $warnings, $header_warnings );
- }
+ foreach ( $detectors as $key => $method ) {
+ $enabled = ! empty( $opts[ $key ] );
+
+ /**
+ * Filters whether one detector runs.
+ *
+ * @since 1.6.0
+ *
+ * @param bool $enabled Whether the detector is enabled.
+ * @param string $key Detector option key.
+ */
+ if ( ! \apply_filters( 'functionalities_assumption_detection_enabled', $enabled, $key ) ) {
+ continue;
+ }
- if ( ! empty( $opts['detect_debug_exposure'] ) ) {
- $debug_warnings = self::detect_debug_exposure();
- $warnings = array_merge( $warnings, $debug_warnings );
- }
+ $found = 'detect_inline_css_growth' === $key
+ ? self::detect_inline_css_growth( $opts )
+ : call_user_func( array( __CLASS__, $method ) );
- if ( ! empty( $opts['detect_cron_issues'] ) ) {
- $cron_warnings = self::detect_cron_issues();
- $warnings = array_merge( $warnings, $cron_warnings );
+ if ( is_array( $found ) && $found ) {
+ $warnings = array_merge( $warnings, $found );
+ }
}
// Filter out ignored warnings.
@@ -229,6 +209,15 @@ function ( $warning ) use ( $ignored ) {
}
);
+ /**
+ * Filters the detected findings before they are stored.
+ *
+ * @since 1.6.0
+ *
+ * @param array $warnings Detected findings.
+ */
+ $warnings = (array) \apply_filters( 'functionalities_assumption_detection_warnings', array_values( $warnings ) );
+
// Store results.
\update_option( self::OPTION_KEY, $warnings );
\update_option( 'functionalities_assumptions_last_run', time() );
diff --git a/includes/features/class-block-cleanup.php b/includes/features/class-block-cleanup.php
index 09a4f69..041121c 100644
--- a/includes/features/class-block-cleanup.php
+++ b/includes/features/class-block-cleanup.php
@@ -84,8 +84,6 @@
*/
class Block_Cleanup {
- use \Functionalities\Traits\Has_Dom_Parser;
-
/**
* Initialize the block cleanup module.
*
@@ -230,11 +228,6 @@ public static function filter_content_cleanup( string $content ): string {
return $content;
}
- // Skip content containing JS-framework directives — DOMDocument corrupts them.
- if ( self::content_has_js_framework_directives( $content ) ) {
- return $content;
- }
-
// Build list of classes to remove based on settings.
$classes_to_remove = array();
@@ -312,43 +305,29 @@ public static function filter_content_cleanup( string $content ): string {
// Store original for filter.
$original = $content;
- // Parse HTML with DOMDocument.
- $libxml_prev = libxml_use_internal_errors( true );
- $dom = new \DOMDocument( '1.0', 'UTF-8' );
- $html = '' . $content . '
';
- $dom->loadHTML( '' . $html );
- $xpath = new \DOMXPath( $dom );
+ // The HTML API removes the class token in place. The DOMDocument pass this
+ // replaced in 1.6.0 reserialized the whole fragment, which is what used to
+ // corrupt Vue and Alpine markup.
+ if ( ! class_exists( '\WP_HTML_Tag_Processor' ) ) {
+ return $content;
+ }
+
+ $processor = new \WP_HTML_Tag_Processor( $content );
- // Remove all specified classes from all elements.
- foreach ( $classes_to_remove as $class ) {
- $class = trim( $class );
- if ( $class === '' ) {
+ while ( $processor->next_tag() ) {
+ if ( null === $processor->get_attribute( 'class' ) ) {
continue;
}
- // Find all elements with this class.
- // XPath 1.0 has no string-escape syntax, so we build the literal via xpath_string_literal()
- // which uses concat() when the value contains both quote types. addcslashes() was incorrect
- // here — XPath does not interpret backslash escapes, so a class containing a quote produced
- // malformed XPath and a silent query failure.
- $needle = self::xpath_string_literal( ' ' . $class . ' ' );
- $nodes = $xpath->query( '//*[contains(concat(" ", normalize-space(@class), " "), ' . $needle . ')]' );
- self::strip_class_from_nodes( $nodes, $class );
- }
-
- // Extract processed content.
- $out = '';
- $wrapper = $dom->getElementById( '__functionalities_wrapper' );
- if ( $wrapper ) {
- foreach ( $wrapper->childNodes as $child ) {
- $out .= $dom->saveHTML( $child );
+ foreach ( $classes_to_remove as $class ) {
+ $class = trim( $class );
+ if ( '' !== $class ) {
+ $processor->remove_class( $class );
+ }
}
}
- libxml_clear_errors();
- libxml_use_internal_errors( $libxml_prev );
-
- $result = $out !== '' ? $out : $content;
+ $result = $processor->get_updated_html();
/**
* Filters the content after block classes have been removed.
@@ -360,84 +339,4 @@ public static function filter_content_cleanup( string $content ): string {
*/
return \apply_filters( 'functionalities_block_cleanup_content', $result, $original );
}
-
- /**
- * Strip a specific class from a list of DOM nodes.
- *
- * Removes the specified class name from all elements in the node list.
- * If the element has no remaining classes after removal, the class
- * attribute is removed entirely.
- *
- * @since 0.2.0
- *
- * @param \DOMNodeList|false $nodes DOMNodeList of elements to process.
- * @param string $class The class name to remove.
- * @return void
- */
- /**
- * Build a safely-quoted XPath 1.0 string literal.
- *
- * XPath 1.0 has no escape syntax inside string literals — a literal with
- * both `'` and `"` must be assembled with `concat()`. This helper picks
- * the simplest safe form for the given input.
- *
- * @since 1.4.6
- *
- * @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 {
- if ( false === strpos( $value, "'" ) ) {
- return "'" . $value . "'";
- }
- if ( false === strpos( $value, '"' ) ) {
- return '"' . $value . '"';
- }
-
- // Both quote types present: split on `'` and concat with literal `'` glue.
- // Each piece is wrapped in single quotes (safe — pieces no longer contain `'`),
- // and the glue is a double-quoted literal containing one apostrophe.
- $parts = array();
- foreach ( explode( "'", $value ) as $i => $piece ) {
- if ( $i > 0 ) {
- $parts[] = '"\'"';
- }
- $parts[] = "'" . $piece . "'";
- }
- return 'concat(' . implode( ',', $parts ) . ')';
- }
-
- protected static function strip_class_from_nodes( $nodes, string $class ): void {
- if ( ! ( $nodes instanceof \DOMNodeList ) ) {
- return;
- }
-
- foreach ( $nodes as $el ) {
- if ( ! $el instanceof \DOMElement ) {
- continue;
- }
-
- $cls = $el->getAttribute( 'class' );
- if ( $cls === '' ) {
- continue;
- }
-
- // Split classes and filter out the target class.
- $parts = preg_split( '/\s+/', $cls );
- $parts = array_filter(
- $parts,
- function ( $c ) use ( $class ) {
- return strtolower( (string) $c ) !== strtolower( $class );
- }
- );
- $parts = array_values( array_unique( $parts ) );
-
- // Update or remove class attribute.
- if ( empty( $parts ) ) {
- $el->removeAttribute( 'class' );
- } else {
- $el->setAttribute( 'class', implode( ' ', $parts ) );
- }
- }
- }
}
diff --git a/includes/features/class-content-regression.php b/includes/features/class-content-regression.php
index f3ae0ab..7b611ad 100644
--- a/includes/features/class-content-regression.php
+++ b/includes/features/class-content-regression.php
@@ -38,6 +38,14 @@ class Content_Regression {
*/
const POST_SETTINGS_KEY = '_functionalities_regression_settings';
+ /**
+ * Post meta key caching the last detection result for the list column.
+ *
+ * @since 1.6.0
+ * @var string
+ */
+ const STATUS_KEY = '_functionalities_regression_status';
+
/**
* Initialize content regression detection.
*
@@ -118,6 +126,28 @@ public static function get_options(): array {
return self::$options;
}
+ /**
+ * Return the post types this module monitors.
+ *
+ * @since 1.6.0
+ * @return array
+ */
+ public static function get_monitored_post_types(): array {
+ $opts = self::get_options();
+ $types = isset( $opts['post_types'] ) && is_array( $opts['post_types'] )
+ ? $opts['post_types']
+ : array( 'post', 'page' );
+
+ /**
+ * Filters the post types Content Integrity monitors.
+ *
+ * @since 1.6.0
+ *
+ * @param array $types Post type slugs.
+ */
+ return (array) \apply_filters( 'functionalities_content_regression_post_types', $types );
+ }
+
/**
* Get per-post settings.
*
@@ -171,8 +201,7 @@ public static function on_save_post( int $post_id, \WP_Post $post, bool $update
$opts = self::get_options();
// Check if post type is enabled.
- $enabled_types = (array) $opts['post_types'];
- if ( ! in_array( $post->post_type, $enabled_types, true ) ) {
+ if ( ! in_array( $post->post_type, self::get_monitored_post_types(), true ) ) {
return;
}
@@ -182,6 +211,18 @@ public static function on_save_post( int $post_id, \WP_Post $post, bool $update
return;
}
+ // Compare against the previous baseline before the new snapshot replaces
+ // it, and cache the outcome for the posts list column.
+ $warnings = self::detect_regressions( $post_id );
+ \update_post_meta(
+ $post_id,
+ self::STATUS_KEY,
+ array(
+ 'count' => count( $warnings ),
+ 'checked' => time(),
+ )
+ );
+
// Capture and store snapshot.
$snapshot = self::capture_snapshot( $post );
self::store_snapshot( $post_id, $snapshot, $opts );
@@ -235,7 +276,7 @@ public static function capture_snapshot( \WP_Post $post ): array {
$word_count = self::count_words( $content, $opts );
$headings = self::parse_headings( $content );
- return array(
+ $snapshot = array(
'internal_link_count' => $links['internal'],
'external_link_count' => $links['external'],
'word_count' => $word_count,
@@ -244,6 +285,16 @@ public static function capture_snapshot( \WP_Post $post ): array {
'timestamp' => time(),
'is_stable_version' => true,
);
+
+ /**
+ * Filters a captured snapshot before it is compared or stored.
+ *
+ * @since 1.6.0
+ *
+ * @param array $snapshot Snapshot data.
+ * @param \WP_Post $post Post the snapshot describes.
+ */
+ return (array) \apply_filters( 'functionalities_content_regression_snapshot', $snapshot, $post );
}
/**
@@ -525,6 +576,16 @@ protected static function store_snapshot( int $post_id, array $snapshot, array $
$existing['rolling_average'] = self::calculate_rolling_average( $existing['snapshots'] );
\update_post_meta( $post_id, self::META_KEY, $existing );
+
+ /**
+ * Fires after a content snapshot has been stored.
+ *
+ * @since 1.6.0
+ *
+ * @param int $post_id Post ID.
+ * @param array $snapshot The snapshot that was stored.
+ */
+ \do_action( 'functionalities_content_regression_snapshot_saved', $post_id, $snapshot );
}
/**
@@ -665,7 +726,15 @@ public static function detect_regressions( int $post_id ): array {
}
}
- return $warnings;
+ /**
+ * Filters the warnings detected for one post.
+ *
+ * @since 1.6.0
+ *
+ * @param array $warnings Detected warnings.
+ * @param int $post_id Post ID.
+ */
+ return (array) \apply_filters( 'functionalities_content_regression_warnings', $warnings, $post_id );
}
/**
@@ -1172,9 +1241,7 @@ public static function setup_post_columns(): void {
return;
}
- $enabled_types = (array) $opts['post_types'];
-
- foreach ( $enabled_types as $post_type ) {
+ foreach ( self::get_monitored_post_types() as $post_type ) {
// WordPress uses different hook names for built-in post types.
if ( 'post' === $post_type ) {
\add_filter( 'manage_posts_columns', array( __CLASS__, 'add_column' ) );
@@ -1213,19 +1280,29 @@ public static function render_column( string $column, int $post_id ): void {
return;
}
- $warnings = self::detect_regressions( $post_id );
+ // Read the result cached at save time. Running full detection here meant
+ // rendering the_content and parsing the DOM once per row on every visit
+ // to the posts list.
+ $status = \get_post_meta( $post_id, self::STATUS_KEY, true );
- if ( empty( $warnings ) ) {
- echo ' ';
- } else {
- $count = count( $warnings );
- $title = sprintf(
- /* translators: %d: number of warnings */
- \_n( '%d issue detected', '%d issues detected', $count, 'functionalities' ),
- $count
- );
- echo ' ';
+ if ( ! is_array( $status ) || ! isset( $status['count'] ) ) {
+ echo ' ';
+ return;
}
+
+ $count = (int) $status['count'];
+
+ if ( 0 === $count ) {
+ echo ' ';
+ return;
+ }
+
+ $title = sprintf(
+ /* translators: %d: number of warnings */
+ \_n( '%d issue detected at the last update', '%d issues detected at the last update', $count, 'functionalities' ),
+ $count
+ );
+ echo ' ';
}
/**
@@ -1246,8 +1323,7 @@ public static function enqueue_editor_assets(): void {
return;
}
- $enabled_types = (array) $opts['post_types'];
- if ( ! in_array( $post->post_type, $enabled_types, true ) ) {
+ if ( ! in_array( $post->post_type, self::get_monitored_post_types(), true ) ) {
return;
}
@@ -1308,8 +1384,7 @@ public static function enqueue_editor_styles(): void {
return;
}
- $enabled_types = (array) $opts['post_types'];
- if ( ! in_array( $post->post_type, $enabled_types, true ) ) {
+ if ( ! in_array( $post->post_type, self::get_monitored_post_types(), true ) ) {
return;
}
diff --git a/includes/features/class-link-management.php b/includes/features/class-link-management.php
index 640e44d..b59cfef 100644
--- a/includes/features/class-link-management.php
+++ b/includes/features/class-link-management.php
@@ -22,7 +22,21 @@
*/
class Link_Management {
- use \Functionalities\Traits\Has_Dom_Parser;
+ /**
+ * Transient holding the resolved exception preset.
+ *
+ * @since 1.6.0
+ * @var string
+ */
+ const PRESET_TRANSIENT = 'functionalities_link_preset';
+
+ /**
+ * Option holding the last successfully resolved preset.
+ *
+ * @since 1.6.0
+ * @var string
+ */
+ const PRESET_FALLBACK_OPTION = 'functionalities_link_preset_last_good';
/**
* Initialize link management features.
@@ -36,13 +50,56 @@ public static function init(): void {
return;
}
- // Load JSON preset immediately (we're already on init hook).
- self::load_json_preset();
-
- // Apply to content, widgets, and comments.
+ // Apply to content, widgets, and comments. The JSON preset is resolved
+ // lazily from process_content() so a request that renders no links never
+ // pays for reading (or fetching) it.
\add_filter( 'the_content', array( __CLASS__, 'filter_content' ), 999 );
\add_filter( 'widget_text', array( __CLASS__, 'filter_content' ), 999 );
\add_filter( 'comment_text', array( __CLASS__, 'filter_content' ), 999 );
+
+ // Keep the cached preset in step with everything that can change it.
+ \add_action( 'update_option_functionalities_link_management', array( __CLASS__, 'flush_preset_cache' ) );
+ \add_action( 'add_option_functionalities_link_management', array( __CLASS__, 'flush_preset_cache' ) );
+ \add_action( 'save_post', array( __CLASS__, 'flush_preset_cache_on_save' ), 10, 2 );
+ \add_action( 'deleted_post', array( __CLASS__, 'flush_preset_cache' ) );
+ \add_action( 'switch_theme', array( __CLASS__, 'flush_preset_cache' ) );
+ }
+
+ /**
+ * Drop the cached preset whenever a post or page is edited.
+ *
+ * Content edits are the moment an author expects a freshly edited exception
+ * list to take effect, so every real save invalidates the cache. Autosaves
+ * and revisions are skipped because they do not change published content and
+ * fire on a timer while the editor is open.
+ *
+ * @since 1.6.0
+ *
+ * @param int $post_id Post ID.
+ * @param \WP_Post $post Post object.
+ * @return void
+ */
+ public static function flush_preset_cache_on_save( $post_id, $post = null ): void {
+ if ( \wp_is_post_autosave( $post_id ) || \wp_is_post_revision( $post_id ) ) {
+ return;
+ }
+
+ if ( $post instanceof \WP_Post && 'auto-draft' === $post->post_status ) {
+ return;
+ }
+
+ self::flush_preset_cache();
+ }
+
+ /**
+ * Clear the cached exception preset.
+ *
+ * @since 1.6.0
+ * @return void
+ */
+ public static function flush_preset_cache(): void {
+ self::$cached_exceptions = null;
+ \delete_transient( self::PRESET_TRANSIENT );
}
/**
@@ -53,11 +110,13 @@ public static function init(): void {
private static $options = null;
/**
- * Cached exceptions.
+ * Request-local copy of the resolved preset exceptions.
*
- * @var array
+ * Null means "not resolved yet this request".
+ *
+ * @var array|null
*/
- private static $cached_exceptions = array();
+ private static $cached_exceptions = null;
/**
* Cached internal exceptions.
@@ -91,9 +150,71 @@ protected static function get_options(): array {
return self::$options;
}
+ /**
+ * Return the exception list from the JSON preset, using the cached copy.
+ *
+ * Resolution is cached in a transient so a remote preset URL is fetched at
+ * most once per cache window instead of once per page load. The cache is
+ * dropped whenever the module settings change, a post or page is edited, or
+ * the theme changes — see flush_preset_cache_on_save().
+ *
+ * @since 1.6.0
+ *
+ * @return array Exception URL list.
+ */
+ public static function get_preset_exceptions(): array {
+ if ( null !== self::$cached_exceptions ) {
+ return self::$cached_exceptions;
+ }
+
+ $cached = \get_transient( self::PRESET_TRANSIENT );
+ if ( is_array( $cached ) ) {
+ self::$cached_exceptions = $cached;
+ return self::$cached_exceptions;
+ }
+
+ /**
+ * Filters how long a resolved exception preset stays cached.
+ *
+ * @since 1.6.0
+ *
+ * @param int $ttl Cache lifetime in seconds.
+ */
+ $ttl = (int) \apply_filters( 'functionalities_link_preset_ttl', 15 * MINUTE_IN_SECONDS );
+ $ttl = max( 60, $ttl );
+ $resolved = self::resolve_json_preset();
+
+ if ( null === $resolved ) {
+ // The source could not be read. Keep serving the last good list and
+ // retry sooner rather than caching an empty result for the full TTL.
+ $fallback = (array) \get_option( self::PRESET_FALLBACK_OPTION, array() );
+ self::$cached_exceptions = $fallback;
+ \set_transient( self::PRESET_TRANSIENT, $fallback, min( $ttl, 5 * MINUTE_IN_SECONDS ) );
+ return self::$cached_exceptions;
+ }
+
+ self::$cached_exceptions = $resolved;
+ \set_transient( self::PRESET_TRANSIENT, $resolved, $ttl );
+ \update_option( self::PRESET_FALLBACK_OPTION, $resolved, false );
+
+ return self::$cached_exceptions;
+ }
+
/**
* Load JSON preset file for exceptions.
*
+ * Retained for backward compatibility with code that called this directly.
+ * Resolution now happens through get_preset_exceptions().
+ *
+ * @return void
+ */
+ public static function load_json_preset(): void {
+ self::get_preset_exceptions();
+ }
+
+ /**
+ * Resolve the JSON preset from its configured source.
+ *
* Checks the following locations in order:
* 1. User-provided custom URL/path (if set)
* 2. Developer filter (functionalities_json_preset_path)
@@ -101,9 +222,11 @@ protected static function get_options(): array {
* 4. Parent theme's exception-urls.json
* 5. Plugin's default exception-urls.json
*
- * @return void
+ * @since 1.6.0
+ *
+ * @return array|null Exception list, or null when the source could not be read.
*/
- public static function load_json_preset(): void {
+ private static function resolve_json_preset(): ?array {
$opts = self::get_options();
$json_path = '';
@@ -142,20 +265,20 @@ public static function load_json_preset(): void {
}
}
- // No valid JSON path found.
+ // No valid JSON path found: nothing to load, and nothing failed.
if ( empty( $json_path ) ) {
- return;
+ return array();
}
// Load JSON content - handle both local files and URLs.
$json_content = self::get_json_content( $json_path );
if ( false === $json_content ) {
- return;
+ return null;
}
$preset = json_decode( $json_content, true );
if ( ! is_array( $preset ) ) {
- return;
+ return null;
}
$json_urls = array();
@@ -168,11 +291,7 @@ public static function load_json_preset(): void {
$json_urls = array_filter( $preset, 'is_string' );
}
- if ( empty( $json_urls ) ) {
- return;
- }
-
- self::$cached_exceptions = $json_urls;
+ return array_values( array_filter( array_map( 'strval', $json_urls ) ) );
}
/**
@@ -275,85 +394,111 @@ public static function process_content( string $content ): string {
return $content;
}
- // Skip content containing JS-framework directives — DOMDocument corrupts them.
- if ( self::content_has_js_framework_directives( $content ) ) {
+ // The HTML API edits attributes in place. Unlike the DOMDocument pass this
+ // replaced in 1.6.0 it never reserializes the document, so Vue and Alpine
+ // directives, mustache interpolation, and any other unknown attribute
+ // survive untouched — which is what the old JS-framework guard existed to
+ // work around.
+ if ( ! class_exists( '\WP_HTML_Tag_Processor' ) ) {
return $content;
}
$opts = self::get_options();
$manual_exceptions = self::parse_exceptions( (string) $opts['exceptions'] );
- $exceptions = array_unique( array_merge( $manual_exceptions, self::$cached_exceptions ) );
+ $exceptions = array_unique( array_merge( $manual_exceptions, self::get_preset_exceptions() ) );
$internal_ex = self::parse_exceptions( (string) $opts['internal_new_tab_exceptions'] );
$site_host = (string) \wp_parse_url( \home_url(), PHP_URL_HOST );
- $libxml_previous = libxml_use_internal_errors( true );
- $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' );
- $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' );
- $parts = preg_split( '/\s+/', strtolower( $rel ) );
- $parts = array_filter( array_unique( array_map( 'trim', (array) $parts ) ) );
- if ( ! in_array( 'nofollow', $parts, true ) ) {
- $parts[] = 'nofollow'; }
- $a->setAttribute( 'rel', implode( ' ', $parts ) );
- }
+ $nofollow_external = ! empty( $opts['nofollow_external'] );
+ $external_new_tab = ! empty( $opts['open_external_new_tab'] );
+ $internal_new_tab = ! empty( $opts['open_internal_new_tab'] );
- // New tab external
- if ( $is_external && ! empty( $opts['open_external_new_tab'] ) ) {
- $a->setAttribute( 'target', '_blank' );
- // add noopener
- $rel = strtolower( (string) $a->getAttribute( 'rel' ) );
- $parts = array_filter( array_unique( preg_split( '/\s+/', $rel ) ) );
- if ( ! in_array( 'noopener', $parts, true ) ) {
- $parts[] = 'noopener'; }
- $a->setAttribute( 'rel', implode( ' ', $parts ) );
- }
+ if ( ! $nofollow_external && ! $external_new_tab && ! $internal_new_tab ) {
+ return $content;
+ }
+
+ $processor = new \WP_HTML_Tag_Processor( $content );
+
+ while ( $processor->next_tag( 'A' ) ) {
+ $href = $processor->get_attribute( 'href' );
+ if ( ! is_string( $href ) || '' === trim( $href ) ) {
+ continue;
+ }
+
+ $is_external = self::is_external_url( $href, $site_host );
+ $rel_tokens = self::rel_tokens( $processor->get_attribute( 'rel' ) );
+ $rel_changed = false;
- // New tab internal (same-domain) except certain domains
- 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; }
- $host = (string) \wp_parse_url( $test, PHP_URL_HOST );
- $host = strtolower( $host );
- $skip = false;
- foreach ( $internal_ex as $exd ) {
- if ( $exd === '' ) {
- continue; }
- if ( $host === $exd || ( $host !== '' && substr( $host, - ( strlen( $exd ) + 1 ) ) === '.' . $exd ) ) {
- $skip = true;
- break;
- }
- }
- if ( ! $skip ) {
- $a->setAttribute( 'target', '_blank' );
- }
+ if ( $is_external && $nofollow_external && ! self::is_exception( $href, $exceptions ) && ! in_array( 'nofollow', $rel_tokens, true ) ) {
+ $rel_tokens[] = 'nofollow';
+ $rel_changed = true;
+ }
+
+ if ( $is_external && $external_new_tab ) {
+ $processor->set_attribute( 'target', '_blank' );
+ if ( ! in_array( 'noopener', $rel_tokens, true ) ) {
+ $rel_tokens[] = 'noopener';
+ $rel_changed = true;
}
}
+
+ if ( ! $is_external && $internal_new_tab && ! self::host_matches_exception( $href, $internal_ex ) ) {
+ $processor->set_attribute( 'target', '_blank' );
+ }
+
+ if ( $rel_changed ) {
+ $processor->set_attribute( 'rel', implode( ' ', $rel_tokens ) );
+ }
}
- $out = '';
- $wrapper = $dom->getElementById( '__functionalities_wrapper' );
- if ( $wrapper ) {
- foreach ( $wrapper->childNodes as $child ) {
- $out .= $dom->saveHTML( $child );
+ return $processor->get_updated_html();
+ }
+
+ /**
+ * Split a rel attribute into lowercase tokens.
+ *
+ * @since 1.6.0
+ *
+ * @param string|true|null $rel Raw rel attribute value.
+ * @return array
+ */
+ private static function rel_tokens( $rel ): array {
+ if ( ! is_string( $rel ) || '' === trim( $rel ) ) {
+ return array();
+ }
+
+ $parts = preg_split( '/\s+/', strtolower( trim( $rel ) ) );
+
+ return array_values( array_filter( array_unique( (array) $parts ) ) );
+ }
+
+ /**
+ * Check whether an internal link host is on the new-tab exception list.
+ *
+ * @since 1.6.0
+ *
+ * @param string $href Link URL.
+ * @param array $exceptions Exception domains.
+ * @return bool
+ */
+ private static function host_matches_exception( string $href, array $exceptions ): bool {
+ $test = 0 === strpos( $href, '//' ) ? 'http:' . $href : $href;
+ $host = strtolower( (string) \wp_parse_url( $test, PHP_URL_HOST ) );
+
+ foreach ( $exceptions as $domain ) {
+ $domain = trim( (string) $domain );
+ if ( '' === $domain ) {
+ continue;
+ }
+ if ( $host === $domain ) {
+ return true;
+ }
+ if ( '' !== $host && substr( $host, - ( strlen( $domain ) + 1 ) ) === '.' . $domain ) {
+ return true;
}
}
- libxml_clear_errors();
- libxml_use_internal_errors( $libxml_previous );
- return $out !== '' ? $out : $content;
+ return false;
}
/**
@@ -401,9 +546,10 @@ protected static function parse_exceptions( string $raw ): array {
* Bulk update links in database.
*
* @param string $target_url The URL to add nofollow to.
+ * @param int $after_id Only consider posts with an ID above this cursor.
* @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, int $after_id = 0 ): array {
global $wpdb;
$target_url = trim( $target_url );
@@ -424,33 +570,47 @@ public static function update_links_in_database( string $target_url ): array {
*/
$batch_limit = \apply_filters( 'functionalities_link_update_batch_limit', 100 );
- // Query posts containing the target URL with limit for performance.
+ // Page by ascending ID. Without a cursor the same first batch is returned
+ // on every run, because posts that already carry nofollow still match the
+ // LIKE, so a site with more matches than the batch limit could never
+ // finish.
+ $after_id = max( 0, $after_id );
+
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct query needed for bulk operation.
$posts = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_content FROM {$wpdb->posts}
WHERE post_content LIKE %s
+ AND ID > %d
AND post_status IN ('publish', 'draft', 'pending', 'future', 'private')
AND post_type NOT IN ('revision', 'nav_menu_item')
+ ORDER BY ID ASC
LIMIT %d",
'%' . $wpdb->esc_like( $target_url ) . '%',
+ $after_id,
$batch_limit
)
);
if ( empty( $posts ) ) {
return array(
- 'success' => true,
- 'count' => 0,
- 'message' => \__( 'No posts found containing this URL.', 'functionalities' ),
+ 'success' => true,
+ 'count' => 0,
+ 'has_more' => false,
+ 'last_id' => $after_id,
+ 'message' => $after_id > 0
+ ? \__( 'Finished. No further posts contain this URL.', 'functionalities' )
+ : \__( 'No posts found containing this URL.', 'functionalities' ),
);
}
$updated_count = 0;
$processed = 0;
+ $last_id = $after_id;
foreach ( $posts as $post ) {
++$processed;
+ $last_id = (int) $post->ID;
// Process links in content.
$new_content = self::add_nofollow_to_url_in_content( $post->post_content, $target_url );
@@ -467,29 +627,27 @@ public static function update_links_in_database( string $target_url ): array {
\clean_post_cache( $post->ID );
++$updated_count;
}
-
- // Prevent timeout on large batches.
- if ( $processed >= $batch_limit ) {
- break;
- }
}
+ $has_more = count( $posts ) >= $batch_limit;
+
$message = sprintf(
- /* translators: %d: Number of posts updated. */
- \__( 'Successfully updated %d post(s).', 'functionalities' ),
- $updated_count
+ /* translators: 1: number of posts updated, 2: number of posts scanned. */
+ \__( 'Updated %1$d of %2$d post(s) scanned.', 'functionalities' ),
+ $updated_count,
+ $processed
);
- // Indicate if there may be more posts to process.
- if ( count( $posts ) >= $batch_limit ) {
- $message .= ' ' . \__( 'There may be more posts - run again to continue.', 'functionalities' );
+ if ( $has_more ) {
+ $message .= ' ' . \__( 'More posts remain — continuing.', 'functionalities' );
}
return array(
'success' => true,
'count' => $updated_count,
'processed' => $processed,
- 'has_more' => count( $posts ) >= $batch_limit,
+ 'has_more' => $has_more,
+ 'last_id' => $last_id,
'message' => $message,
);
}
diff --git a/includes/features/class-login-security.php b/includes/features/class-login-security.php
index 398dbb4..7ce1320 100644
--- a/includes/features/class-login-security.php
+++ b/includes/features/class-login-security.php
@@ -36,6 +36,22 @@ class Login_Security {
*/
private const LOCKOUT_PREFIX = 'funct_login_lockout_';
+ /**
+ * Failed attempts by username prefix.
+ *
+ * @since 1.6.0
+ * @var string
+ */
+ private const USER_ATTEMPTS_PREFIX = 'funct_login_user_attempts_';
+
+ /**
+ * Username lockout prefix.
+ *
+ * @since 1.6.0
+ * @var string
+ */
+ private const USER_LOCKOUT_PREFIX = 'funct_login_user_lockout_';
+
/**
* Initialize the feature.
*
@@ -56,6 +72,10 @@ public static function init(): void {
\add_filter( 'login_errors', array( __CLASS__, 'custom_login_error' ) );
}
+ if ( \is_admin() ) {
+ \add_action( 'wp_ajax_functionalities_login_unlock', array( __CLASS__, 'ajax_unlock' ) );
+ }
+
// Disable XML-RPC authentication.
if ( ! empty( $opts['disable_xmlrpc_auth'] ) ) {
\add_filter( 'xmlrpc_enabled', '__return_false' );
@@ -111,6 +131,8 @@ public static function get_options(): array {
'disable_application_passwords' => false,
'hide_login_errors' => true,
'trust_proxy_headers' => false,
+ 'lock_usernames' => true,
+ 'allowlist_ips' => '',
'custom_logo_url' => '',
'custom_background_color' => '',
'custom_form_background' => '',
@@ -163,7 +185,47 @@ private static function get_client_ip(): string {
}
/**
- * Check if IP is locked out.
+ * Check whether an IP is exempt from lockouts.
+ *
+ * Behind a CDN or reverse proxy with proxy headers switched off, every
+ * visitor shares one REMOTE_ADDR, so a single attacker can lock out the whole
+ * site. An allowlist gives the site owner a way back in.
+ *
+ * @since 1.6.0
+ *
+ * @param string $ip Client IP.
+ * @return bool
+ */
+ public static function is_allowlisted( string $ip ): bool {
+ if ( '' === $ip ) {
+ return false;
+ }
+
+ $opts = self::get_options();
+ $raw = (string) ( $opts['allowlist_ips'] ?? '' );
+ if ( '' === trim( $raw ) ) {
+ return false;
+ }
+
+ foreach ( preg_split( '/[\r\n,]+/', $raw ) as $entry ) {
+ $entry = trim( (string) $entry );
+ if ( '' === $entry ) {
+ continue;
+ }
+ if ( $entry === $ip ) {
+ return true;
+ }
+ // Prefix wildcard, for example 203.0.113.*
+ if ( '*' === substr( $entry, -1 ) && 0 === strpos( $ip, rtrim( $entry, '*' ) ) ) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if the request is locked out by IP or by username.
*
* @param mixed $user User object or error.
* @param string $username Username.
@@ -175,11 +237,19 @@ public static function check_lockout( $user, $username, $password ) {
return $user;
}
- $ip = self::get_client_ip();
- $lockout_key = self::LOCKOUT_PREFIX . md5( $ip );
+ $ip = self::get_client_ip();
+ if ( self::is_allowlisted( $ip ) ) {
+ return $user;
+ }
+
+ $opts = self::get_options();
+ $locked = (bool) \get_transient( self::LOCKOUT_PREFIX . md5( $ip ) );
+
+ if ( ! $locked && ! empty( $opts['lock_usernames'] ) ) {
+ $locked = (bool) \get_transient( self::USER_LOCKOUT_PREFIX . md5( strtolower( (string) $username ) ) );
+ }
- if ( \get_transient( $lockout_key ) ) {
- $opts = self::get_options();
+ if ( $locked ) {
return new \WP_Error(
'too_many_attempts',
sprintf(
@@ -200,10 +270,17 @@ public static function check_lockout( $user, $username, $password ) {
* @return void
*/
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 );
+ $opts = self::get_options();
+ $ip = self::get_client_ip();
+
+ if ( self::is_allowlisted( $ip ) ) {
+ return;
+ }
+
+ $attempts_key = self::ATTEMPTS_PREFIX . md5( $ip );
+ $lockout_key = self::LOCKOUT_PREFIX . md5( $ip );
+ $max_attempts = isset( $opts['max_attempts'] ) ? (int) $opts['max_attempts'] : 5;
+ $lockout_duration = isset( $opts['lockout_duration'] ) ? (int) $opts['lockout_duration'] : 15;
// Get current attempts.
$attempts = (int) \get_transient( $attempts_key );
@@ -213,9 +290,7 @@ public static function record_failed_attempt( $username ): void {
\set_transient( $attempts_key, $attempts, HOUR_IN_SECONDS );
// Check if should lockout.
- $max_attempts = isset( $opts['max_attempts'] ) ? (int) $opts['max_attempts'] : 5;
if ( $attempts >= $max_attempts ) {
- $lockout_duration = isset( $opts['lockout_duration'] ) ? (int) $opts['lockout_duration'] : 15;
\set_transient( $lockout_key, true, $lockout_duration * MINUTE_IN_SECONDS );
// Log the lockout.
@@ -224,6 +299,24 @@ public static function record_failed_attempt( $username ): void {
// Clear attempts after lockout.
\delete_transient( $attempts_key );
}
+
+ // Also throttle per username, so a distributed attempt against one
+ // account is caught even when each request comes from a fresh address.
+ if ( empty( $opts['lock_usernames'] ) || '' === (string) $username ) {
+ return;
+ }
+
+ $user_hash = md5( strtolower( (string) $username ) );
+ $user_attempts_key = self::USER_ATTEMPTS_PREFIX . $user_hash;
+ $user_attempts = (int) \get_transient( $user_attempts_key ) + 1;
+
+ \set_transient( $user_attempts_key, $user_attempts, HOUR_IN_SECONDS );
+
+ if ( $user_attempts >= max( $max_attempts, 1 ) * 2 ) {
+ \set_transient( self::USER_LOCKOUT_PREFIX . $user_hash, true, $lockout_duration * MINUTE_IN_SECONDS );
+ self::log_lockout( $ip, $username, $user_attempts );
+ \delete_transient( $user_attempts_key );
+ }
}
/**
@@ -234,9 +327,13 @@ public static function record_failed_attempt( $username ): void {
* @return void
*/
public static function clear_attempts( $username, $user ): void {
- $ip = self::get_client_ip();
- $attempts_key = self::ATTEMPTS_PREFIX . md5( $ip );
- \delete_transient( $attempts_key );
+ \delete_transient( self::ATTEMPTS_PREFIX . md5( self::get_client_ip() ) );
+
+ if ( '' !== (string) $username ) {
+ $user_hash = md5( strtolower( (string) $username ) );
+ \delete_transient( self::USER_ATTEMPTS_PREFIX . $user_hash );
+ \delete_transient( self::USER_LOCKOUT_PREFIX . $user_hash );
+ }
}
/**
@@ -464,4 +561,72 @@ public static function unlock_ip( string $ip ): bool {
return true;
}
+
+ /**
+ * Manually unlock a username.
+ *
+ * @since 1.6.0
+ *
+ * @param string $username Username.
+ * @return bool
+ */
+ public static function unlock_username( string $username ): bool {
+ if ( '' === $username ) {
+ return false;
+ }
+
+ $hash = md5( strtolower( $username ) );
+ \delete_transient( self::USER_ATTEMPTS_PREFIX . $hash );
+ \delete_transient( self::USER_LOCKOUT_PREFIX . $hash );
+
+ return true;
+ }
+
+ /**
+ * Handle the unlock button on the lockout log.
+ *
+ * @since 1.6.0
+ * @return void
+ */
+ public static function ajax_unlock(): void {
+ \check_ajax_referer( 'functionalities_login_unlock', 'nonce' );
+
+ if ( ! \current_user_can( 'manage_options' ) ) {
+ \wp_send_json_error( array( 'message' => \__( 'Insufficient permissions.', 'functionalities' ) ), 403 );
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by check_ajax_referer above.
+ $ip = isset( $_POST['ip'] ) ? \sanitize_text_field( \wp_unslash( $_POST['ip'] ) ) : '';
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified by check_ajax_referer above.
+ $username = isset( $_POST['username'] ) ? \sanitize_text_field( \wp_unslash( $_POST['username'] ) ) : '';
+
+ if ( '' !== $ip ) {
+ self::unlock_ip( $ip );
+ }
+ if ( '' !== $username ) {
+ self::unlock_username( $username );
+ }
+
+ \wp_send_json_success( array( 'message' => \__( 'Unlocked.', 'functionalities' ) ) );
+ }
+
+ /**
+ * Report whether every recent lockout came from a single address.
+ *
+ * That pattern normally means the site sits behind a proxy or CDN and every
+ * visitor shares one REMOTE_ADDR, so IP lockouts hit everyone at once.
+ *
+ * @since 1.6.0
+ * @return bool
+ */
+ public static function lockouts_share_one_ip(): bool {
+ $logs = self::get_lockout_log( 10 );
+ if ( count( $logs ) < 5 ) {
+ return false;
+ }
+
+ $ips = array_unique( array_filter( \wp_list_pluck( $logs, 'ip' ) ) );
+
+ return 1 === count( $ips );
+ }
}
diff --git a/includes/features/class-misc.php b/includes/features/class-misc.php
index 78985c3..bf04522 100644
--- a/includes/features/class-misc.php
+++ b/includes/features/class-misc.php
@@ -66,9 +66,9 @@
* @param string $url The theme URL.
*
* @example
- * // Use a different Prism theme
+ * // Point at a different Prism theme shipped with your own theme or plugin.
* add_filter( 'functionalities_misc_prism_theme_url', function( $url ) {
- * return 'https://unpkg.com/prismjs@1.29.0/themes/prism-tomorrow.min.css';
+ * return get_stylesheet_directory_uri() . '/css/prism-tomorrow.min.css';
* } );
*
* ## Actions
@@ -83,6 +83,14 @@
*/
class Misc {
+ /**
+ * Version of the bundled Prism build, used for asset cache busting.
+ *
+ * @since 1.6.0
+ * @var string
+ */
+ const PRISM_VERSION = '1.29.0';
+
/**
* Initialize the misc module.
*
@@ -206,10 +214,12 @@ function () {
// Limit revisions.
if ( self::is_option_enabled( $opts, 'limit_revisions' ) ) {
+ $keep = isset( $opts['revisions_limit'] ) ? (int) $opts['revisions_limit'] : 10;
+ $keep = max( 0, min( 100, $keep ) );
\add_filter(
'wp_revisions_to_keep',
- function () {
- return 10;
+ function () use ( $keep ) {
+ return $keep;
}
);
}
@@ -227,11 +237,16 @@ function () {
);
}
- // Disable Heartbeat API.
+ // Disable Heartbeat API. Deregistering it in wp-admin also breaks post
+ // locking and autosave, so admin is a separate opt-in.
if ( self::is_option_enabled( $opts, 'disable_heartbeat' ) ) {
+ $disable_in_admin = self::is_option_enabled( $opts, 'disable_heartbeat_admin' );
\add_action(
'init',
- function () {
+ function () use ( $disable_in_admin ) {
+ if ( \is_admin() && ! $disable_in_admin ) {
+ return;
+ }
\wp_deregister_script( 'heartbeat' );
},
1
@@ -351,8 +366,10 @@ protected static function get_options(): array {
'remove_dns_prefetch' => false,
'remove_recent_comments_css' => false,
'limit_revisions' => false,
+ 'revisions_limit' => 10,
'disable_dashicons_for_guests' => false,
'disable_heartbeat' => false,
+ 'disable_heartbeat_admin' => false,
'disable_admin_bar_front' => false,
'remove_jquery_migrate' => false,
'enable_prism_admin' => false,
@@ -398,26 +415,28 @@ public static function enqueue_prism(): void {
/**
* Filters the Prism.js theme CSS URL.
*
+ * Defaults to the copy bundled with the plugin. WordPress.org guideline 8
+ * forbids loading executable code from a third-party host, so the CDN
+ * build this used to pull from is no longer the default.
+ *
* @since 0.8.0
*
* @param string $url The theme URL.
*/
$theme_url = \apply_filters(
'functionalities_misc_prism_theme_url',
- 'https://unpkg.com/prismjs@1.29.0/themes/prism.min.css'
+ FUNCTIONALITIES_URL . 'assets/vendor/prism/prism.min.css'
);
- // phpcs:ignore PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- Admin-only feature using Prism.js for code highlighting. CDN used for performance.
- \wp_enqueue_style( 'functionalities-prism', $theme_url, array(), FUNCTIONALITIES_VERSION );
- // phpcs:ignore PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- Admin-only feature using Prism.js for code highlighting. CDN used for performance.
- \wp_enqueue_script( 'functionalities-prism', 'https://unpkg.com/prismjs@1.29.0/prism.min.js', array(), FUNCTIONALITIES_VERSION, true );
-
- \add_action(
- 'admin_print_footer_scripts',
- function () {
- echo '';
- }
+ \wp_enqueue_style( 'functionalities-prism', $theme_url, array(), self::PRISM_VERSION );
+ \wp_enqueue_script(
+ 'functionalities-prism',
+ FUNCTIONALITIES_URL . 'assets/vendor/prism/prism.min.js',
+ array(),
+ self::PRISM_VERSION,
+ true
);
+ \wp_add_inline_script( 'functionalities-prism', 'window.Prism&&Prism.highlightAll();' );
}
/**
@@ -522,8 +541,10 @@ function ( $endpoints ) {
*/
protected static function disable_feeds(): void {
$callback = function () {
- // Redirect to homepage.
- if ( function_exists( 'wp_safe_redirect' ) ) {
+ // Redirect to the homepage when a redirect is still possible. Once
+ // headers are out, fall back to a plain message instead of emitting
+ // a warning and continuing to render the feed.
+ if ( ! headers_sent() ) {
\wp_safe_redirect( \home_url( '/' ), 301 );
exit;
}
diff --git a/includes/features/class-pwa.php b/includes/features/class-pwa.php
index 497ac1c..4840dd9 100644
--- a/includes/features/class-pwa.php
+++ b/includes/features/class-pwa.php
@@ -76,7 +76,9 @@ public static function init(): void {
}
\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 );
+ // Rewrite flushing runs from Module_Registry::handle_option_update(), which
+ // is hooked to updated_option and added_option. Hooking it here as well
+ // flushed the rules twice on every save.
if ( ! self::is_enabled() ) {
return;
@@ -283,6 +285,7 @@ private static function build_manifest(): array {
$site_desc = \get_bloginfo( 'description' );
$manifest = array(
+ 'id' => ! empty( $opts['scope'] ) ? $opts['scope'] : '/',
'name' => ! empty( $opts['app_name'] ) ? $opts['app_name'] : $site_name,
'short_name' => ! empty( $opts['short_name'] ) ? $opts['short_name'] : $site_name,
'description' => ! empty( $opts['description'] ) ? $opts['description'] : $site_desc,
@@ -626,6 +629,19 @@ private static function output_service_worker(): void {
$offline_json = \wp_json_encode( $config['offline_url'] );
$precache_js = implode( ',', array_map( '\\wp_json_encode', $precache ) );
+ $excluded_json = \wp_json_encode(
+ array_values(
+ array_unique(
+ (array) \apply_filters(
+ 'functionalities_pwa_excluded_paths',
+ array( '/wp-admin', '/wp-login.php', '/wp-json', '/wp-cron.php', '/xmlrpc.php' )
+ )
+ )
+ )
+ );
+ $max_entries = (int) \apply_filters( 'functionalities_pwa_runtime_cache_limit', 60 );
+ $max_entries = max( 10, min( 500, $max_entries ) );
+
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- All values are wp_json_encode'd (safe for JS context).
echo '/* Functionalities PWA Service Worker */' . "\n";
echo 'const CACHE_VERSION=' . $ver_json . ';' . "\n";
@@ -634,12 +650,20 @@ private static function output_service_worker(): void {
echo 'const IMAGE_CACHE=`func-pwa-images-${CACHE_VERSION}`;' . "\n";
echo 'const OFFLINE_URL=' . $offline_json . ';' . "\n";
echo 'const PRECACHE_URLS=[' . $precache_js . '];' . "\n";
+ echo 'const EXCLUDED_PATHS=' . $excluded_json . ';' . "\n";
+ echo 'const MAX_ENTRIES=' . (int) $max_entries . ';' . "\n";
// phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
// Service worker body (static JS, no PHP interpolation).
// phpcs:disable Generic.Strings.UnnecessaryStringConcat.Found
+
+ // Precache each URL on its own. cache.addAll() rejects the whole install
+ // if any single entry 404s, so one stale precache URL used to stop the
+ // service worker installing at all.
echo 'self.addEventListener("install",e=>{' .
- 'e.waitUntil(caches.open(CORE_CACHE).then(c=>c.addAll(PRECACHE_URLS)).then(()=>self.skipWaiting()));' .
+ 'e.waitUntil(caches.open(CORE_CACHE).then(c=>Promise.all(' .
+ 'PRECACHE_URLS.map(u=>c.add(new Request(u,{credentials:"same-origin"})).catch(()=>null))' .
+ ')).then(()=>self.skipWaiting()));' .
'});' . "\n";
echo 'self.addEventListener("activate",e=>{' .
@@ -650,17 +674,42 @@ private static function output_service_worker(): void {
']));' .
'});' . "\n";
+ // Never store administration screens, REST responses, or anything the
+ // server marks as private. A shared device must not keep a logged-in
+ // editor's pages in Cache Storage.
+ echo 'function isExcluded(url){' .
+ 'const p=url.pathname;' .
+ 'return EXCLUDED_PATHS.some(x=>p===x||p.startsWith(x+"/")||p.startsWith(x+"?"))||p.startsWith("/wp-admin");' .
+ '}' . "\n";
+
+ echo 'function isPrivate(res){' .
+ 'if(!res)return true;' .
+ 'const cc=res.headers.get("Cache-Control")||"";' .
+ 'return /no-store|private/i.test(cc);' .
+ '}' . "\n";
+
+ echo 'async function trim(cache){' .
+ 'const keys=await cache.keys();' .
+ 'if(keys.length<=MAX_ENTRIES)return;' .
+ 'for(let i=0;i{if(res&&res.status===200)cache.put(req,res.clone());return res;}).catch(()=>cached);' .
+ 'const net=fetch(req).then(async res=>{await put(cache,req,res);return res;}).catch(()=>cached);' .
'return cached||net;' .
'}' . "\n";
echo 'self.addEventListener("fetch",e=>{' .
'if(e.request.method!=="GET")return;' .
+ 'const url=new URL(e.request.url);' .
+ 'if(url.origin!==self.location.origin)return;' .
+ 'if(isExcluded(url))return;' .
+ 'if(url.search&&e.request.mode!=="navigate")return;' .
'const dest=e.request.destination;' .
'if(e.request.mode==="navigate"){e.respondWith(networkFirst(e.request));return;}' .
'if(dest==="image"){e.respondWith(cacheFirst(e.request));return;}' .
diff --git a/includes/features/class-redirect-manager.php b/includes/features/class-redirect-manager.php
index da0c0a8..73dd5f8 100644
--- a/includes/features/class-redirect-manager.php
+++ b/includes/features/class-redirect-manager.php
@@ -19,6 +19,22 @@
*/
class Redirect_Manager {
+ /**
+ * Option holding buffered hit counts and 404 aggregates.
+ *
+ * @since 1.6.0
+ * @var string
+ */
+ const BUFFER_OPTION = 'functionalities_redirect_hit_buffer';
+
+ /**
+ * Cron hook that flushes the write buffer.
+ *
+ * @since 1.6.0
+ * @var string
+ */
+ const FLUSH_HOOK = 'functionalities_redirect_flush_buffer';
+
/**
* Redirects file path.
*
@@ -53,8 +69,8 @@ class Redirect_Manager {
* @return 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';
+ self::$redirects_file = \Functionalities\Storage\Data_Directory::file( 'redirects.json' );
+ self::$log_file = \Functionalities\Storage\Data_Directory::file( '404-log.json' );
$opts = (array) \get_option( 'functionalities_redirect_manager', array( 'enabled' => false ) );
@@ -62,17 +78,29 @@ public static function init(): void {
return;
}
- // Handle redirects early on frontend.
- \add_action( 'template_redirect', array( __CLASS__, 'handle_redirect' ), 1 );
+ // Handle redirects before the main query runs. template_redirect fires
+ // after WordPress has already queried the database for a page it is about
+ // to throw away.
+ \add_action( 'parse_request', array( __CLASS__, 'handle_redirect' ), 1 );
if ( ! empty( $opts['monitor_404'] ) ) {
\add_action( 'template_redirect', array( __CLASS__, 'maybe_log_404' ), 99 );
}
+ // Buffered hits and 404 aggregates reach disk in batches.
+ \add_action( self::FLUSH_HOOK, array( __CLASS__, 'flush_buffer' ) );
+ if ( ! \wp_next_scheduled( self::FLUSH_HOOK ) ) {
+ \wp_schedule_event( time() + 300, 'hourly', self::FLUSH_HOOK );
+ }
+
// Only register admin handlers in admin.
if ( ! \is_admin() ) {
return;
}
+ // Show current numbers on the management screen.
+ \add_action( 'load-toplevel_page_functionalities', array( __CLASS__, 'flush_buffer' ) );
+ \add_action( 'load-functionalities_page_functionalities-redirect-manager', array( __CLASS__, 'flush_buffer' ) );
+
// AJAX handlers.
\add_action( 'wp_ajax_functionalities_redirect_add', array( __CLASS__, 'ajax_add_redirect' ) );
\add_action( 'wp_ajax_functionalities_redirect_update', array( __CLASS__, 'ajax_update_redirect' ) );
@@ -107,19 +135,12 @@ private static function get_filesystem() {
*/
private static function get_redirects_dir() {
$dir = dirname( self::$redirects_file );
- if ( ! file_exists( $dir ) ) {
- if ( ! wp_mkdir_p( $dir ) ) {
- return false;
- }
- // Security files.
- $index_file = $dir . '/index.php';
- if ( ! file_exists( $index_file ) ) {
- $fs = self::get_filesystem();
- if ( $fs ) {
- $fs->put_contents( $index_file, ' (int) ( $existing['count'] ?? 0 ) + 1,
+ 'last_seen' => time(),
+ 'origin' => $meta['origin'] ?? ( $existing['origin'] ?? '' ),
+ );
+ }
+
+ $buffer['started'] = isset( $buffer['started'] ) ? (int) $buffer['started'] : time();
+
+ \update_option( self::BUFFER_OPTION, $buffer, false );
+
+ $pending = count( $buffer['hits'] ?? array() ) + count( $buffer['not_found'] ?? array() );
+
+ /**
+ * Filters how many buffered events trigger a flush to disk.
+ *
+ * @since 1.6.0
+ *
+ * @param int $threshold Number of distinct buffered entries.
+ */
+ $threshold = (int) \apply_filters( 'functionalities_redirect_buffer_threshold', 25 );
+
+ if ( $pending >= max( 1, $threshold ) || ( time() - (int) $buffer['started'] ) > 5 * MINUTE_IN_SECONDS ) {
+ self::flush_buffer();
+ }
+ }
+
+ /**
+ * Merge buffered hits and 404 aggregates into their JSON files.
+ *
+ * @since 1.6.0
+ * @return void
+ */
+ public static function flush_buffer(): void {
+ $buffer = (array) \get_option( self::BUFFER_OPTION, array() );
+ if ( empty( $buffer['hits'] ) && empty( $buffer['not_found'] ) ) {
+ return;
+ }
+
+ // Clear first: a failed write must not replay the same counts forever.
+ \delete_option( self::BUFFER_OPTION );
+
+ if ( ! empty( $buffer['hits'] ) ) {
+ $hits = $buffer['hits'];
+ self::mutate_redirects(
+ static function ( array $redirects ) use ( $hits ) {
+ $changed = false;
+ foreach ( $redirects as &$redirect ) {
+ $id = $redirect['id'] ?? '';
+ if ( '' !== $id && isset( $hits[ $id ] ) ) {
+ $redirect['hits'] = (int) ( $redirect['hits'] ?? 0 ) + (int) $hits[ $id ];
+ $changed = true;
+ }
}
+ unset( $redirect );
+ return $changed ? $redirects : false;
}
- return false;
- }
- );
+ );
+ }
+
+ if ( ! empty( $buffer['not_found'] ) ) {
+ self::write_not_found( $buffer['not_found'] );
+ }
+ }
+
+ /**
+ * Return buffered hit counts that have not reached the file yet.
+ *
+ * @since 1.6.0
+ * @return array Redirect ID to pending count.
+ */
+ public static function get_buffered_hits(): array {
+ $buffer = (array) \get_option( self::BUFFER_OPTION, array() );
+ return isset( $buffer['hits'] ) && is_array( $buffer['hits'] ) ? $buffer['hits'] : array();
}
/**
@@ -623,15 +731,20 @@ static function ( array $redirects ) use ( $id ) {
public static function get_stats(): array {
$redirects = self::get_redirects();
- $total = count( $redirects );
- $enabled = 0;
- $hits = 0;
+ $total = count( $redirects );
+ $enabled = 0;
+ $hits = 0;
+ $buffered = self::get_buffered_hits();
foreach ( $redirects as $r ) {
if ( ! empty( $r['enabled'] ) ) {
++$enabled;
}
- $hits += $r['hits'] ?? 0;
+ $hits += (int) ( $r['hits'] ?? 0 );
+ $id = $r['id'] ?? '';
+ if ( '' !== $id && isset( $buffered[ $id ] ) ) {
+ $hits += (int) $buffered[ $id ];
+ }
}
return array(
@@ -854,39 +967,63 @@ public static function maybe_log_404(): void {
$origin = sanitize_text_field( (string) wp_parse_url( \wp_unslash( $_SERVER['HTTP_REFERER'] ), PHP_URL_HOST ) );
}
+ self::buffer_event( 'not_found', $path, array( 'origin' => $origin ) );
+ }
+
+ /**
+ * Merge buffered 404 aggregates into the bounded log file.
+ *
+ * @since 1.6.0
+ *
+ * @param array $entries Path to aggregate data.
+ * @return void
+ */
+ private static function write_not_found( array $entries ): void {
+ $options = (array) \get_option( 'functionalities_redirect_manager', array() );
$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 ) {
+ static function ( array $data ) use ( $entries, $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;
+ static function ( $item ) use ( $cutoff ) {
+ return is_array( $item ) && (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;
+
+ foreach ( $entries as $path => $entry ) {
+ $count = (int) ( $entry['count'] ?? 1 );
+ $origin = (string) ( $entry['origin'] ?? '' );
+ $seen = (int) ( $entry['last_seen'] ?? time() );
+
+ if ( isset( $items[ $path ] ) ) {
+ $items[ $path ]['count'] = (int) $items[ $path ]['count'] + $count;
+ $items[ $path ]['last_seen'] = $seen;
+ if ( '' !== $origin ) {
+ $items[ $path ]['referrer_origin'] = $origin;
+ }
+ continue;
}
- } else {
+
$items[ $path ] = array(
'path' => $path,
- 'count' => 1,
- 'last_seen' => time(),
+ 'count' => $count,
+ 'last_seen' => $seen,
'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;
},
diff --git a/includes/features/class-schema.php b/includes/features/class-schema.php
index 2a47abd..a71eeb4 100644
--- a/includes/features/class-schema.php
+++ b/includes/features/class-schema.php
@@ -93,8 +93,6 @@
*/
class Schema {
- use \Functionalities\Traits\Has_Dom_Parser;
-
/**
* Initialize the schema module.
*
@@ -340,11 +338,6 @@ public static function filter_article( string $content ): string {
return $content;
}
- // Skip content containing JS-framework directives — DOMDocument corrupts them.
- if ( self::content_has_js_framework_directives( $content ) ) {
- return $content;
- }
-
// Store original for filter.
$original = $content;
@@ -358,83 +351,62 @@ public static function filter_article( string $content ): string {
$type = \apply_filters( 'functionalities_schema_article_itemtype', $opts['article_itemtype'] ?? 'Article' );
$type = preg_replace( '/[^A-Za-z]/', '', (string) $type );
- // Parse HTML with DOMDocument.
- $libxml_prev = libxml_use_internal_errors( true );
- $dom = new \DOMDocument( '1.0', 'UTF-8' );
- $dom->loadHTML( '' . $content . '
' );
- $xpath = new \DOMXPath( $dom );
- $wrap = $dom->getElementById( '___fwrap' );
-
- if ( ! $wrap ) {
+ // Edit attributes in place with the HTML API. The DOMDocument pass this
+ // replaced in 1.6.0 reserialized the fragment, which corrupted Vue and
+ // Alpine templates and required a separate guard.
+ if ( ! class_exists( '\WP_HTML_Tag_Processor' ) ) {
return $content;
}
- // Find target element: prefer existing , else first element child.
- $target = null;
- $article = $xpath->query( './/article', $wrap );
-
- if ( $article instanceof \DOMNodeList && $article->length > 0 ) {
- $node = $article->item( 0 );
- if ( $node instanceof \DOMElement ) {
- $target = $node;
+ $processor = new \WP_HTML_Tag_Processor( $content );
+ $has_article = false;
+ $did_headline = false;
+ $did_author = false;
+ $add_headline = ! empty( $opts['add_headline'] );
+ $add_dates = ! empty( $opts['add_dates'] );
+ $add_author = ! empty( $opts['add_author'] );
+
+ while ( $processor->next_tag() ) {
+ $tag = $processor->get_tag();
+
+ // Article schema is only applied when the content carries a real
+ // element, matching the behaviour before 1.6.0.
+ if ( ! $has_article && 'ARTICLE' === $tag ) {
+ $processor->set_attribute( 'itemscope', true );
+ $processor->set_attribute( 'itemtype', 'https://schema.org/' . $type );
+ $has_article = true;
+ continue;
}
- }
- // Add article schema attributes only if tag exists.
- if ( $target instanceof \DOMElement ) {
- $target->setAttribute( 'itemscope', '' );
- $target->setAttribute( 'itemtype', 'https://schema.org/' . $type );
- }
-
- // Add headline itemprop.
- if ( ! empty( $opts['add_headline'] ) ) {
- $headings = $xpath->query( './/h1|.//h2' );
- if ( $headings instanceof \DOMNodeList && $headings->length > 0 ) {
- $el = $headings->item( 0 );
- if ( $el instanceof \DOMElement ) {
- $el->setAttribute( 'itemprop', 'headline' );
- }
+ if ( $add_headline && ! $did_headline && ( 'H1' === $tag || 'H2' === $tag ) ) {
+ $processor->set_attribute( 'itemprop', 'headline' );
+ $did_headline = true;
+ continue;
}
- }
- // Add date itemprops.
- if ( ! empty( $opts['add_dates'] ) ) {
- $times = $xpath->query( './/time' );
- if ( $times instanceof \DOMNodeList ) {
- foreach ( $times as $time ) {
- if ( $time instanceof \DOMElement ) {
- $cls = strtolower( (string) $time->getAttribute( 'class' ) );
- if ( strpos( $cls, 'published' ) !== false ) {
- $time->setAttribute( 'itemprop', 'datePublished' );
- } elseif ( strpos( $cls, 'updated' ) !== false || strpos( $cls, 'modified' ) !== false ) {
- $time->setAttribute( 'itemprop', 'dateModified' );
- }
- }
+ if ( $add_dates && 'TIME' === $tag ) {
+ $class = $processor->get_attribute( 'class' );
+ $class = is_string( $class ) ? strtolower( $class ) : '';
+ if ( false !== strpos( $class, 'published' ) ) {
+ $processor->set_attribute( 'itemprop', 'datePublished' );
+ } elseif ( false !== strpos( $class, 'updated' ) || false !== strpos( $class, 'modified' ) ) {
+ $processor->set_attribute( 'itemprop', 'dateModified' );
}
+ continue;
}
- }
- // Add author itemprop.
- if ( ! empty( $opts['add_author'] ) ) {
- $author = $xpath->query( './/*[contains(@class, "author")]' );
- if ( $author instanceof \DOMNodeList && $author->length > 0 ) {
- $el = $author->item( 0 );
- if ( $el instanceof \DOMElement ) {
- $el->setAttribute( 'itemprop', 'author' );
+ if ( $add_author && ! $did_author ) {
+ $class = $processor->get_attribute( 'class' );
+ if ( is_string( $class ) && false !== strpos( strtolower( $class ), 'author' ) ) {
+ $processor->set_attribute( 'itemprop', 'author' );
+ $did_author = true;
}
}
}
- // Extract processed content.
- $out = '';
- foreach ( $wrap->childNodes as $child ) {
- $out .= $dom->saveHTML( $child );
- }
-
- libxml_clear_errors();
- libxml_use_internal_errors( $libxml_prev );
+ $out = $processor->get_updated_html();
- $result = $out !== '' ? $out : $content;
+ $result = '' !== $out ? $out : $content;
/**
* Filters the content after article schema has been applied.
diff --git a/includes/features/class-snippets.php b/includes/features/class-snippets.php
index 91cc4be..7e7b5e2 100644
--- a/includes/features/class-snippets.php
+++ b/includes/features/class-snippets.php
@@ -278,8 +278,8 @@ private static function output_snippets( string $location, string $filter, strin
$code = \apply_filters( $filter, (string) $snippet['code'], $snippet );
if ( ! empty( $code ) ) {
- // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped via escape_snippet() with unfiltered_html cap check and kses_with_styles fallback.
- $parts[] = self::escape_snippet( $code );
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Filtered at save time against the author's unfiltered_html capability; see escape_snippet().
+ $parts[] = self::escape_snippet( $code, is_array( $snippet ) ? $snippet : array() );
}
}
@@ -443,24 +443,38 @@ public static function snippet_allowed_tags(): array {
}
/**
- * Escape a code snippet for safe output.
+ * Prepare a stored snippet for output.
*
- * Users with `unfiltered_html` capability get raw output; others
- * get content filtered via kses_with_styles() which preserves CSS
- * inside `