diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 3ab6587..d1a01fb 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -96,6 +96,7 @@ jobs:
functionalities/assets/css/admin-brand.css \
functionalities/assets/css/admin-icons.css \
functionalities/assets/css/admin-polish.css \
+ functionalities/assets/css/components-editor.css \
functionalities/assets/js/admin-polish.js \
functionalities/assets/js/admin-link-health.js \
functionalities/assets/js/admin-link-health-editor.js \
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f028aaa..929ab16 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -123,6 +123,7 @@ jobs:
"includes/core/class-wordpress-7-integration.php" \
"includes/storage/class-data-directory.php" \
"assets/vendor/prism/prism.min.js" \
+ "assets/css/components-editor.css" \
"languages/functionalities.pot" \
"assets/js/wp7-admin.js" \
"assets/js/wp7-admin.asset.php" \
diff --git a/assets/css/components-editor.css b/assets/css/components-editor.css
new file mode 100644
index 0000000..27ec6b3
--- /dev/null
+++ b/assets/css/components-editor.css
@@ -0,0 +1,2 @@
+/* Real stylesheet anchor for component rules added through enqueue_block_assets.
+ Keep this asset in the package: WordPress carries its inline CSS into the editor iframe. */
diff --git a/assets/js/admin-link-health.js b/assets/js/admin-link-health.js
index 356f071..0b4e0fe 100644
--- a/assets/js/admin-link-health.js
+++ b/assets/js/admin-link-health.js
@@ -131,6 +131,7 @@
const fields = Object.fromEntries(new FormData(form));
delete fields.action;
delete fields.operation;
+ delete fields.run;
const data = await request(operation, fields);
if (revision === epoch) {
suspended = false;
diff --git a/build.sh b/build.sh
index 66ce645..5a255aa 100755
--- a/build.sh
+++ b/build.sh
@@ -45,6 +45,7 @@ REQUIRED=(
"assets/css/admin-brand.css"
"assets/css/admin-icons.css"
"assets/css/admin-polish.css"
+ "assets/css/components-editor.css"
"assets/js/admin-polish.js"
"assets/js/admin-link-health.js"
"assets/js/admin-link-health-editor.js"
diff --git a/docs/release-fixes-verification.md b/docs/release-fixes-verification.md
new file mode 100644
index 0000000..8d19c94
--- /dev/null
+++ b/docs/release-fixes-verification.md
@@ -0,0 +1,34 @@
+# v1.7.0 release-fix verification
+
+The release review findings are covered by regression tests and a Gatilab verification pass. The checks below cover the v1.7.0 release contents.
+
+## Corrected behavior
+
+- Link edits defer File-block unlinks, bound URLs, and serialized block settings to the post editor. Valid block-comment whitespace cannot bypass the guard; safety checks inspect beyond the scan limit.
+- Source fingerprints and edit revisions prevent a partial scan or overlapping worker from restoring an obsolete link offset.
+- Published-post edits dispatch WordPress's status transition and edit/save hooks, including feed-cache invalidation.
+- Recheck, Ignore, and reconciliation read current post/metadata caches under the shared lock. Native controls require the displayed scan generation; AJAX uses its live generation rather than a stale hidden field.
+- Filtered CSV exports traverse each source once. The 100,000-link synthetic benchmark fell from 162,000 queries to 81, retaining every row. This benchmark uses production report code with a zero-latency simulated database.
+- Site Activity uses final per-item installation results, distinguishes manual AJAX skins from automatic skins, and waits for automatic rollback outcomes.
+- The JSON preset field has a translated accessible label.
+- Components enqueue a real editor stylesheet handle with inline rules through `enqueue_block_assets`, so the canvas does not depend on generated uploads CSS or the settings-style channel.
+
+## Automated checks
+
+- `composer check` with the real WordPress 6.3 HTML API: 180 tests, 6,978 assertions; syntax, coding standards, and PHP compatibility checks passed.
+- The 51 utility tests also passed with WordPress 6.9 core; the five asset tests passed on both core versions.
+- `npm test`: 19 tests passed.
+- Original defects were reproduced before changes, including nine failures in an isolated copy of `f85d308` with the new tests. Cache races, the native/AJAX generation interaction, and canvas asset delivery each received explicit failing-then-passing checks.
+- Translation freshness, package required-file checks, and whitespace checks passed. The runtime package contains 151 files; development fixtures remain excluded.
+
+## Gatilab runtime checks
+
+Tested on WordPress 7.1.2 and PHP 8.5.10. Original content and settings were snapshotted before installing the candidate. Only disposable fixtures were edited.
+
+A six-link fixture was scanned for four links, stopped, unlinked, replaced through the browser's reviewed edit flow, and resumed. Every remaining URL was checked before completion. A deliberately cached feed timestamp was invalidated. Parallel real WordPress requests retained both recheck results and both Ignore choices. Browser filters and downloaded CSV agreed on the two ignored fixture rows. Final upgrade callbacks were exercised with real WordPress automatic and manual AJAX skin classes, without updating unrelated installed plugins.
+
+Editor checks were completed in Brave Origin. The configured variable font appeared in the picker and canvas with a normalized `100 1000` face. SVG Icon transformed to Core Icon and back with its SVG and 48px size preserved. Component styles were verified with generated CSS present and with generation deliberately unavailable through a temporary QA-only path filter; the badge retained its background and padding in both cases.
+
+The temporary path filter and both fixtures were removed. Original module options, link report metadata, and scan state matched the deployment snapshot after cleanup. Concurrent, unrelated content edits were preserved; cleanup did not restore a database over other changes. Existing activity history was retained, and update-outcome test events used isolated private storage.
+
+Deployment backups and detailed verification receipts are kept outside the repository. Before repeating these checks, snapshot the target site's settings, source content, report metadata, and scan state; scope edits to disposable fixtures; then remove only test-owned records and compare the preserved data.
diff --git a/includes/admin/class-module-controller.php b/includes/admin/class-module-controller.php
index 2d40264..69d09cd 100644
--- a/includes/admin/class-module-controller.php
+++ b/includes/admin/class-module-controller.php
@@ -525,7 +525,7 @@ public static function field_json_preset_url(): void {
?>
-
+
diff --git a/includes/admin/trait-admin-utilities-ui.php b/includes/admin/trait-admin-utilities-ui.php
index 8f4ea3f..0648ab2 100644
--- a/includes/admin/trait-admin-utilities-ui.php
+++ b/includes/admin/trait-admin-utilities-ui.php
@@ -92,6 +92,10 @@ public static function handle_link_health_action(): void {
$operation = \sanitize_key( \wp_unslash( $_POST['operation'] ?? '' ) );
$post_id = \absint( $_POST['post_id'] ?? 0 );
$url = Link_Health_Controller::request_url();
+ $run = isset( $_POST['run'] ) && is_string( $_POST['run'] ) ? \sanitize_text_field( \wp_unslash( $_POST['run'] ) ) : '';
+ if ( in_array( $operation, array( 'resume', 'stop' ), true ) && '' === $run ) {
+ \wp_die( \esc_html__( 'Refresh the scan status before continuing.', 'functionalities' ), '', array( 'response' => 400 ) );
+ }
if ( ! \Functionalities\Core\Module_Registry::is_enabled( 'link-health' ) ) {
\wp_die( \esc_html__( 'Enable Link Health before running an action.', 'functionalities' ), '', array( 'response' => 403 ) );
}
@@ -100,10 +104,10 @@ public static function handle_link_health_action(): void {
$result = \Functionalities\Features\Link_Health::start_scan();
break;
case 'resume':
- $result = 'stopped' === ( \Functionalities\Features\Link_Health::state()['status'] ?? '' ) ? \Functionalities\Features\Link_Health::resume_scan() : \Functionalities\Features\Link_Health::run_batch();
+ $result = 'stopped' === ( \Functionalities\Features\Link_Health::state()['status'] ?? '' ) ? \Functionalities\Features\Link_Health::resume_scan( $run ) : \Functionalities\Features\Link_Health::run_batch( $run );
break;
case 'stop':
- $result = \Functionalities\Features\Link_Health::stop_scan();
+ $result = \Functionalities\Features\Link_Health::stop_scan( $run );
break;
case 'ignore':
$result = \Functionalities\Features\Link_Health::set_ignored( $post_id, $url, true );
@@ -152,16 +156,11 @@ private static function export_link_health(): void {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Stream a generated CSV download.
$stream = fopen( 'php://output', 'w' );
fputcsv( $stream, array( 'Post ID', 'Title', 'URL', 'Status', 'HTTP', 'Checked UTC', 'Stale', 'Complete', 'Truncated' ), ',', '"', '' );
- $page = 1;
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Caller already verifies the administrator and export nonce.
$filters = \Functionalities\Features\Link_Health_Report::filters( \wp_unslash( $_POST ) );
- do {
- $report = \Functionalities\Features\Link_Health::report_page( $page, $filters );
- foreach ( $report['rows'] as $row ) {
- fputcsv( $stream, array( $row['post_id'], self::csv_safe( $row['title'] ), self::csv_safe( $row['url'] ), $row['status'], $row['code'], $row['checked'] ? gmdate( 'c', $row['checked'] ) : '', (int) $row['stale'], (int) $row['complete'], (int) $row['truncated'] ), ',', '"', '' );
- }
- ++$page;
- } while ( $page <= $report['pages'] );
+ foreach ( \Functionalities\Features\Link_Health_Report::export_rows( $filters ) as $row ) {
+ fputcsv( $stream, array( $row['post_id'], self::csv_safe( $row['title'] ), self::csv_safe( $row['url'] ), $row['status'], $row['code'], $row['checked'] ? gmdate( 'c', $row['checked'] ) : '', (int) $row['stale'], (int) $row['complete'], (int) $row['truncated'] ), ',', '"', '' );
+ }
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Complete the CSV response stream.
fclose( $stream );
exit;
@@ -177,6 +176,9 @@ private static function link_health_button( string $operation, string $label, in
';
+ }
if ( 'export' === $operation ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only selected report filters.
foreach ( \Functionalities\Features\Link_Health_Report::filters( \wp_unslash( $_GET ) ) as $key => $value ) {
diff --git a/includes/features/class-components.php b/includes/features/class-components.php
index a5ddf89..c3b8745 100644
--- a/includes/features/class-components.php
+++ b/includes/features/class-components.php
@@ -108,14 +108,31 @@ public static function init(): void {
\add_action( 'wp_enqueue_scripts', array( __CLASS__, 'print_footer_link' ) );
\add_action( 'admin_enqueue_scripts', array( __CLASS__, 'print_footer_link' ) );
- // Serve the block editor canvas (an iframe) via the editor `styles`
- // setting — the only channel guaranteed to reach the iframe.
+ // A real asset handle carries inline rules into the iframe on the block-assets channel.
+ \add_action( 'enqueue_block_assets', array( __CLASS__, 'enqueue_editor_assets' ) );
+
+ // Retain settings styles for editor integrations using that channel.
\add_filter( 'block_editor_settings_all', array( __CLASS__, 'add_editor_settings_components' ) );
// Regenerate CSS file when settings are updated.
\add_action( 'update_option_functionalities_components', array( __CLASS__, 'on_option_update' ), 10, 2 );
}
+ /** Deliver component rules to the canvas without depending on writable uploads. */
+ public static function enqueue_editor_assets(): void {
+ if ( ! \is_admin() || ! \Functionalities\Core\Module_Registry::is_enabled( 'components' ) ) {
+ return;
+ }
+ $opts = self::get_options();
+ $items = \apply_filters( 'functionalities_components_items', $opts['items'] );
+ $css = self::sanitize_css( self::build_css( $items ) );
+ if ( '' === $css ) {
+ return;
+ }
+ \wp_enqueue_style( 'functionalities-components-editor', FUNCTIONALITIES_URL . 'assets/css/components-editor.css', array(), FUNCTIONALITIES_VERSION );
+ \wp_add_inline_style( 'functionalities-components-editor', $css );
+ }
+
/**
* Cached options.
*
@@ -160,16 +177,9 @@ protected static function get_options(): array {
/**
* Inject component CSS into the block editor's style settings.
*
- * The editor canvas is iframed (WP 6.3+/7.x). Feeding CSS through the editor
- * `styles` setting — the channel add_editor_style() and the Font Library use —
- * is the reliable way into that iframe: WordPress copies it in verbatim and
- * scopes the selectors to the content wrapper.
- *
- * This replaces an enqueue_block_assets path that only reached the iframe when
- * a generated CSS file existed; its inline fallback used a src-less style handle
- * (wp_register_style( $h, false ) + wp_add_inline_style) that does NOT cross
- * into the iframe, so component CSS silently vanished in the editor whenever the
- * uploads file could not be written.
+ * Retain this channel for editor integrations that consume settings styles.
+ * The native canvas also receives the real stylesheet handle from
+ * enqueue_editor_assets(), independent of generated uploads and theme filters.
*
* @since 1.4.7
*
diff --git a/includes/features/class-link-health-editor.php b/includes/features/class-link-health-editor.php
index 82f37f9..acc533b 100644
--- a/includes/features/class-link-health-editor.php
+++ b/includes/features/class-link-health-editor.php
@@ -36,8 +36,20 @@ private static function valid_destination( string $url ): bool {
return strlen( $url ) <= 2048 && ! preg_match( '/[\x00-\x20\x7f]/', $url ) && is_array( $parts ) && in_array( strtolower( $parts['scheme'] ?? '' ), array( 'http', 'https' ), true ) && ! empty( $parts['host'] ) && ! isset( $parts['user'] ) && ! isset( $parts['pass'] );
}
+ /** Safety checks must inspect all anchors, including those beyond the scan limit. */
+ private static function html_references( string $html, string $base, string $from ): bool {
+ $parser = new \WP_HTML_Tag_Processor( $html );
+ while ( $parser->next_tag( 'A' ) ) {
+ $href = $parser->get_attribute( 'href' );
+ if ( is_string( $href ) && Link_Health::normalize_url( $href, $base ) === $from ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/** A quick HTML edit must not disagree with a block's separately serialized settings. */
- private static function block_settings_reference( array $blocks, string $base, string $from ): bool {
+ private static function block_settings_reference( array $blocks, string $base, string $from, string $operation ): bool {
$contains = static function ( $values ) use ( &$contains, $base, $from ): bool {
foreach ( (array) $values as $key => $value ) {
if ( is_array( $value ) && $contains( $value ) ) {
@@ -47,13 +59,17 @@ private static function block_settings_reference( array $blocks, string $base, s
$is_url = preg_match( '~^(?:https?:)?//|^[/?#]~i', $value ) || preg_match( '/(?:url|href|link|src)$/i', (string) $key );
if ( $is_url && Link_Health::normalize_url( $value, $base ) === $from ) {
return true; }
- if ( false !== strpos( $value, '<' ) && in_array( $from, Link_Health::extract_links( $value, $base ), true ) ) {
+ if ( false !== strpos( $value, '<' ) && self::html_references( $value, $base, $from ) ) {
return true; }
}
return false;
};
foreach ( $blocks as $block ) {
- if ( $contains( $block['attrs'] ?? array() ) || self::block_settings_reference( $block['innerBlocks'] ?? array(), $base, $from ) ) {
+ $structured = ! empty( $block['attrs']['metadata']['bindings'] ) || ( 'unlink' === $operation && 'core/file' === ( $block['blockName'] ?? '' ) );
+ if ( $structured && self::html_references( $block['innerHTML'] ?? '', $base, $from ) ) {
+ return true;
+ }
+ if ( $contains( $block['attrs'] ?? array() ) || self::block_settings_reference( $block['innerBlocks'] ?? array(), $base, $from, $operation ) ) {
return true; }
}
return false;
@@ -64,8 +80,8 @@ public static function transform( string $content, string $base, string $from, s
if ( ! in_array( $operation, array( 'replace', 'unlink' ), true ) || ( 'replace' === $operation && ! self::valid_destination( $to ) ) ) {
return new \WP_Error( 'invalid_destination', \__( 'Enter a complete http:// or https:// URL without credentials or spaces.', 'functionalities' ) );
}
- if ( false !== strpos( $content, 'Card';
+ $this->assertSame( 'custom/card', parse_blocks( $content )[0]['blockName'] );
+ $this->assertInstanceOf( WP_Error::class, \Functionalities\Features\Link_Health_Editor::transform( $content, $base, $old, 'replace', $base . 'new' ) );
+ }
+ }
+ public function test_release_fix_file_blocks_preserve_their_filename_structure(): void {
+ $this->require_core(); $base = 'https://example.test/'; $old = $base . 'old';
+ $file = '