Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down
2 changes: 2 additions & 0 deletions assets/css/components-editor.css
Original file line number Diff line number Diff line change
@@ -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. */
1 change: 1 addition & 0 deletions assets/js/admin-link-health.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
34 changes: 34 additions & 0 deletions docs/release-fixes-verification.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion includes/admin/class-module-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ public static function field_json_preset_url(): void {
?>
<div class="functionalities-json-picker">
<div class="functionalities-json-picker-input">
<input type="text" id="functionalities_json_preset_url" class="regular-text code" name="functionalities_link_management[json_preset_url]" aria-label="' . \esc_attr__( 'JSON preset path or URL', 'functionalities' ) . '" value="<?php echo \esc_attr( $val ); ?>" placeholder="<?php echo \esc_attr( FUNCTIONALITIES_DIR . 'exception-urls.json' ); ?>" />
<input type="text" id="functionalities_json_preset_url" class="regular-text code" name="functionalities_link_management[json_preset_url]" aria-label="<?php echo \esc_attr__( 'JSON preset path or URL', 'functionalities' ); ?>" value="<?php echo \esc_attr( $val ); ?>" placeholder="<?php echo \esc_attr( FUNCTIONALITIES_DIR . 'exception-urls.json' ); ?>" />
<button type="button" id="functionalities_json_browse_btn" class="button button-secondary">
<?php echo \esc_html__( 'Browse...', 'functionalities' ); ?>
</button>
Expand Down
22 changes: 12 additions & 10 deletions includes/admin/trait-admin-utilities-ui.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) );
}
Expand All @@ -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 );
Expand Down Expand Up @@ -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;
Expand All @@ -177,6 +176,9 @@ private static function link_health_button( string $operation, string $label, in
<input type="hidden" name="post_id" value="<?php echo \esc_attr( $post_id ); ?>">
<input type="hidden" name="url" value="<?php echo \esc_attr( $url ); ?>">
<?php
if ( in_array( $operation, array( 'resume', 'stop' ), true ) ) {
echo '<input type="hidden" name="run" value="' . \esc_attr( \Functionalities\Features\Link_Health::state()['run'] ?? '' ) . '">';
}
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 ) {
Expand Down
34 changes: 22 additions & 12 deletions includes/features/class-components.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
*
Expand Down
29 changes: 24 additions & 5 deletions includes/features/class-link-health-editor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ) {
Expand All @@ -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;
Expand All @@ -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, '<!-- wp:' ) && self::block_settings_reference( \parse_blocks( $content ), $base, $from ) ) {
return new \WP_Error( 'block_settings', \__( 'This URL is also stored in block settings. Edit it in the post editor to keep the block consistent.', 'functionalities' ) );
if ( self::block_settings_reference( \parse_blocks( $content ), $base, $from, $operation ) ) {
return new \WP_Error( 'block_settings', \__( 'This link uses block settings, bindings, or a structure that needs the post editor. Edit it there to keep the block consistent.', 'functionalities' ) );
}
$parser = new class( $content ) extends \WP_HTML_Tag_Processor {
/** Isolate the core 6.3/6.5 bookmark span difference in one adapter. */
Expand Down Expand Up @@ -216,6 +232,9 @@ public static function apply( int $post_id, string $token ) {
\update_post_meta( $post_id, '_edit_last', \get_current_user_id() );
\clean_post_cache( $post_id );
$after = \get_post( $post_id );
\wp_transition_post_status( $after->post_status, $post->post_status, $after );
\do_action( 'edit_post_' . $post->post_type, $post_id, $after );
\do_action( 'edit_post', $post_id, $after );
\do_action( 'post_updated', $post_id, $after, $post );
\do_action( 'save_post_' . $post->post_type, $post_id, $after, true );
\do_action( 'save_post', $post_id, $after, true );
Expand Down
Loading
Loading