Skip to content

Commit b254dee

Browse files
committed
fix: register generated companion blocks before editor validation
AI assistance: OpenAI gpt-5.6-terra via OpenCode traced late companion activation past init, implemented immediate registration verification and missing-block editor contracts, and ran the SSI suite. Chris Huber remains responsible for every line.
1 parent 7235a9e commit b254dee

5 files changed

Lines changed: 144 additions & 13 deletions

File tree

includes/class-static-site-importer-plugin-materializer.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,10 @@ public static function ensure_generated_plugin(
210210
if ( false === $plan['activate'] ) {
211211
// mu-plugins are always active; no activation call is required.
212212
$plugin_file = (string) $descriptor['plugin_file'];
213+
$registered = self::register_generated_blocks( $descriptor, $plan );
214+
if ( is_wp_error( $registered ) ) {
215+
return self::failed_report( $report, $registered );
216+
}
213217
self::replace_active_generated_companion( $plugin_file, $report );
214218
if ( function_exists( 'update_option' ) ) {
215219
update_option( self::ACTIVE_COMPANION_OPTION, $plugin_file, false );
@@ -247,6 +251,10 @@ public static function ensure_generated_plugin(
247251
)
248252
);
249253
}
254+
$registered = self::register_generated_blocks( $descriptor, $plan );
255+
if ( is_wp_error( $registered ) ) {
256+
return self::failed_report( $report, $registered );
257+
}
250258
self::replace_active_generated_companion( $plugin_file, $report );
251259
if ( function_exists( 'update_option' ) ) {
252260
update_option( self::ACTIVE_COMPANION_OPTION, $plugin_file, false );
@@ -256,6 +264,53 @@ public static function ensure_generated_plugin(
256264
return $report;
257265
}
258266

267+
/**
268+
* Register a newly materialized companion in this request.
269+
*
270+
* WordPress activation sandbox-loads a plugin after `init` has usually run.
271+
* Generated companions register their metadata blocks on `init`, so invoke that
272+
* exact callback now and verify every emitted block is available to the editor.
273+
*
274+
* @param array<string,mixed> $descriptor Generated companion descriptor.
275+
* @param array<string,mixed> $plan Generated companion install plan.
276+
* @return true|WP_Error
277+
*/
278+
private static function register_generated_blocks( array $descriptor, array $plan ) {
279+
$slug = isset( $descriptor['slug'] ) && is_string( $descriptor['slug'] ) ? $descriptor['slug'] : '';
280+
$plugin_file = isset( $descriptor['plugin_file'] ) && is_string( $descriptor['plugin_file'] ) ? $descriptor['plugin_file'] : '';
281+
$base_dir = isset( $plan['base_dir'] ) && is_string( $plan['base_dir'] ) ? $plan['base_dir'] : '';
282+
$callback = str_replace( '-', '_', $slug ) . '_register_blocks';
283+
$path = '' === $base_dir || '' === $plugin_file ? '' : rtrim( $base_dir, '/\\' ) . '/' . $plugin_file;
284+
285+
if ( '' === $slug || '' === $path || ! is_readable( $path ) ) {
286+
return new WP_Error( 'static_site_importer_companion_plugin_registration_unavailable', 'Generated companion block registration file is unavailable.' );
287+
}
288+
if ( ! function_exists( $callback ) ) {
289+
require_once $path;
290+
}
291+
if ( ! is_callable( $callback ) ) {
292+
return new WP_Error( 'static_site_importer_companion_plugin_registration_unavailable', sprintf( 'Generated companion %s does not expose its block registration callback.', $slug ) );
293+
}
294+
295+
call_user_func( $callback );
296+
$registry = class_exists( 'WP_Block_Type_Registry' ) ? WP_Block_Type_Registry::get_instance() : null;
297+
$missing = array();
298+
foreach ( $descriptor['block_names'] ?? array() as $block_name ) {
299+
if ( ! is_string( $block_name ) || '' === $block_name || ! $registry || ! $registry->is_registered( $block_name ) ) {
300+
$missing[] = $block_name;
301+
}
302+
}
303+
if ( ! empty( $missing ) ) {
304+
return new WP_Error(
305+
'static_site_importer_companion_plugin_registration_incomplete',
306+
'Generated companion blocks were not registered before editor use.',
307+
array( 'missing_block_names' => $missing )
308+
);
309+
}
310+
311+
return true;
312+
}
313+
259314
/** Preflight registered block names before generated files are written. */
260315
private static function generated_block_name_collision( array $block_names, array $descriptor ) {
261316
$registry = class_exists( 'WP_Block_Type_Registry' ) ? WP_Block_Type_Registry::get_instance() : null;

lib/fixture-matrix/collectors/editor-validation.mjs

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,18 @@ function pickCount(value, fallback) {
134134
}
135135

136136
function isInvalidEditorBlock(block) {
137-
if (!block || typeof block !== 'object') {
138-
return false;
139-
}
140-
if (block.isValid === false || block.is_valid === false || block.valid === false) {
137+
if (!block || typeof block !== 'object') {
138+
return false;
139+
}
140+
// Gutenberg preserves the unavailable block's intended name on core/missing.
141+
// validateBlock considers that fallback structurally valid, but it cannot be
142+
// edited as the generated companion block that the import declared.
143+
if ((block.name === 'core/missing' || block.block_name === 'core/missing' || block.blockName === 'core/missing')
144+
&& typeof (block.originalName || block.original_name) === 'string'
145+
&& (block.originalName || block.original_name).length > 0) {
146+
return true;
147+
}
148+
if (block.isValid === false || block.is_valid === false || block.valid === false) {
141149
return true;
142150
}
143151
// A `validateBlock`-style result: [isValid, issues] or { isValid }.
@@ -148,7 +156,9 @@ function isInvalidEditorBlock(block) {
148156
}
149157

150158
function editorInvalidBlockDiagnostic(block) {
151-
const name = block.name || block.block_name || block.blockName || '';
159+
const observedName = block.name || block.block_name || block.blockName || '';
160+
const originalName = block.originalName || block.original_name || '';
161+
const name = observedName === 'core/missing' && originalName ? originalName : observedName;
152162
const clientId = block.clientId || block.client_id || '';
153163
const selector = block.selector || (clientId ? `[data-block="${clientId}"]` : '');
154164
const detail = firstString([
@@ -160,13 +170,14 @@ function editorInvalidBlockDiagnostic(block) {
160170
]);
161171
return {
162172
kind: EDITOR_BLOCK_INVALID_KIND,
163-
block_name: name,
164-
observed_block_name: name,
173+
block_name: name,
174+
observed_block_name: observedName,
175+
...(originalName ? { original_block_name: originalName } : {}),
165176
selector,
166177
source_path: block.source_path || block.source || '',
167178
observed_output: firstString([block.originalContent, block.expectedContent, block.html_excerpt]),
168-
message: `Editor reported block "${name || 'unknown'}" as invalid: ${detail}.`,
169-
};
179+
message: `Editor reported block "${name || 'unknown'}" as invalid: ${detail}.`,
180+
};
170181
}
171182

172183
function collectEditorCanvasInvalidGroups(payload) {

test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"tests": [
88
{ "path": "tests/form-materializer-smoke.php", "environment": "standalone-php" },
99
{ "path": "tests/provider-adapter-runtime-smoke.php", "environment": "standalone-php" },
10+
{ "path": "tests/editor-validation-contract.test.mjs", "environment": "node" },
1011
{ "path": "tests/host-dependency-resolver.test.mjs", "environment": "node" },
1112
{ "path": "tests/smoke-ability-error-report-summary.php", "environment": "standalone-php" },
1213
{ "path": "tests/smoke-ability-import-success-diagnostics.php", "environment": "standalone-php" },
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import assert from 'node:assert/strict';
2+
import test from 'node:test';
3+
4+
import {
5+
collectEditorValidation,
6+
collectEditorValidationDiagnostics,
7+
} from '../lib/fixture-matrix/collectors/editor-validation.mjs';
8+
9+
test('fails editor validation when Gutenberg substitutes core/missing for a companion block', () => {
10+
const payload = {
11+
schema: 'wp-codebox/editor-validate-blocks/v1',
12+
results: [
13+
{ name: 'blocks-engine/form-select', isValid: true },
14+
{ name: 'core/missing', originalName: 'blocks-engine/form-input', isValid: true },
15+
],
16+
};
17+
18+
assert.deepEqual(collectEditorValidation(payload), {
19+
validation_method: 'wp.blocks.validateBlock',
20+
validation_provider: 'wordpress-block-editor',
21+
total_blocks: 2,
22+
valid_blocks: 1,
23+
invalid_blocks: 1,
24+
});
25+
assert.deepEqual(collectEditorValidationDiagnostics(payload), [
26+
{
27+
kind: 'editor_block_invalid',
28+
block_name: 'blocks-engine/form-input',
29+
observed_block_name: 'core/missing',
30+
original_block_name: 'blocks-engine/form-input',
31+
selector: '',
32+
source_path: '',
33+
observed_output: '',
34+
message: 'Editor reported block "blocks-engine/form-input" as invalid: This block contains unexpected or invalid content.',
35+
},
36+
]);
37+
});

tests/smoke-companion-plugin.php

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ function is_plugin_active( string $plugin_file ): bool {
150150
function activate_plugin( string $plugin_file ) {
151151
$GLOBALS['ssi_companion_active'][] = $plugin_file;
152152
$GLOBALS['ssi_companion_activated'][] = $plugin_file;
153+
require_once WP_PLUGIN_DIR . '/' . $plugin_file;
153154
return null;
154155
}
155156
}
@@ -436,17 +437,16 @@ function update_option( string $name, mixed $value, bool $autoload = false ): bo
436437
$assert( file_exists( WP_PLUGIN_DIR . '/ssi-example-site/blocks/custom-hero/render.php' ), 'install-writes-render-php-to-disk' );
437438
$assert( file_exists( WP_PLUGIN_DIR . '/ssi-example-site/blocks/custom-hero/block.json' ), 'install-emits-block-json' );
438439
$assert( file_exists( WP_PLUGIN_DIR . '/ssi-example-site/blocks/custom-hero/editor.js' ), 'install-emits-declared-editor-asset' );
440+
$assert( in_array( 'example/custom-hero', WP_Block_Type_Registry::$registered, true ), 'install-registers-generated-block-before-editor-use' );
441+
$assert( isset( $GLOBALS['static_site_importer_companion_block_owners']['example/custom-hero'] ), 'install-records-generated-block-owner-before-editor-use' );
439442
$written_main = file_exists( WP_PLUGIN_DIR . '/ssi-example-site/ssi-example-site.php' ) ? (string) file_get_contents( WP_PLUGIN_DIR . '/ssi-example-site/ssi-example-site.php' ) : '';
440443
$assert( str_contains( $written_main, 'register_block_type' ), 'written-main-file-registers-blocks' );
441444

442445
// A foreign registration that wins before generated plugin init must never be
443446
// marked as companion-owned, so a later materialization still fails closed.
444447
WP_Block_Type_Registry::$registered[] = 'example/custom-hero';
445448
$GLOBALS['static_site_importer_companion_block_owners'] = array();
446-
require WP_PLUGIN_DIR . '/ssi-example-site/ssi-example-site.php';
447-
foreach ( $GLOBALS['ssi_companion_actions']['init'] ?? array() as $callback ) {
448-
call_user_func( $callback );
449-
}
449+
ssi_example_site_register_blocks();
450450
$assert( ! isset( $GLOBALS['static_site_importer_companion_block_owners']['example/custom-hero'] ), 'foreign-registration-before-generated-init-records-no-owner' );
451451
$foreign_init_collision = Static_Site_Importer_Plugin_Materializer::ensure_generated_plugin( $payload, static fn (): bool => true );
452452
$assert( 'failed' === ( $foreign_init_collision['status'] ?? '' ) && 'runtime_block_name_collision' === ( $foreign_init_collision['diagnostics'][0]['reason_code'] ?? '' ), 'foreign-registration-before-generated-init-blocks-refresh' );
@@ -522,12 +522,39 @@ function update_option( string $name, mixed $value, bool $autoload = false ): bo
522522

523523
// A new site replaces the previous regular companion so document-global
524524
// scripts from separate imports cannot execute together.
525+
$GLOBALS['static_site_importer_companion_block_owners'] = array();
526+
WP_Block_Type_Registry::$registered = array();
525527
$replacement_payload = array_merge( $payload, array( 'site_slug' => 'replacement-site' ) );
526528
$replacement_report = Static_Site_Importer_Plugin_Materializer::ensure_generated_plugin( $replacement_payload );
527529
$assert( in_array( 'ssi-example-site/ssi-example-site.php', $GLOBALS['ssi_companion_deactivated'], true ), 'replacement-deactivates-previous-companion' );
528530
$assert( in_array( 'replaced:ssi-example-site/ssi-example-site.php', $replacement_report['actions'] ?? array(), true ), 'replacement-reports-previous-companion' );
529531
$assert( 'ssi-replacement-site/ssi-replacement-site.php' === get_option( Static_Site_Importer_Plugin_Materializer::ACTIVE_COMPANION_OPTION ), 'replacement-records-current-companion-plugin' );
530532

533+
$control_payload = array_merge(
534+
$payload,
535+
array(
536+
'site_slug' => 'editor-controls',
537+
'blocks' => array(
538+
array(
539+
'name' => 'form-select',
540+
'block_json' => array( 'name' => 'blocks-engine/form-select', 'title' => 'Form Select', 'editorScript' => 'file:./editor.js' ),
541+
'render' => '<select><option>One</option></select>',
542+
'assets' => array( 'editor.js' => 'window.SSIFormSelect = true;' ),
543+
),
544+
array(
545+
'name' => 'form-input',
546+
'block_json' => array( 'name' => 'blocks-engine/form-input', 'title' => 'Form Input', 'editorScript' => 'file:./editor.js' ),
547+
'render' => '<input type="text">',
548+
'assets' => array( 'editor.js' => 'window.SSIFormInput = true;' ),
549+
),
550+
),
551+
)
552+
);
553+
$control_report = Static_Site_Importer_Plugin_Materializer::ensure_generated_plugin( $control_payload );
554+
$assert( 'installed_activated' === ( $control_report['status'] ?? '' ), 'control-companion-materializes' );
555+
$assert( in_array( 'blocks-engine/form-select', WP_Block_Type_Registry::$registered, true ), 'form-select-registers-before-editor-use' );
556+
$assert( in_array( 'blocks-engine/form-input', WP_Block_Type_Registry::$registered, true ), 'form-input-registers-before-editor-use' );
557+
531558
// Cleanup generated fixtures.
532559
$cleanup = static function ( string $dir ) use ( &$cleanup ): void {
533560
if ( ! is_dir( $dir ) ) {

0 commit comments

Comments
 (0)