Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,14 @@ public static function materialize_plugin_dependencies( array $adapter ): array
(string) ( $dependency['plugin_file'] ?? '' ),
$dependency['availability_callback'] ?? null
);
$prepare = $dependency['preparation_callback'] ?? null;
if ( 'completed' === ( $reports[ $slug ]['status'] ?? '' ) && is_callable( $prepare ) ) {
$prepared = call_user_func( $prepare );
if ( function_exists( 'is_wp_error' ) && is_wp_error( $prepared ) ) {
$reports[ $slug ]['status'] = 'failed';
$reports[ $slug ]['reason'] = $prepared->get_error_code();
}
}
}

foreach ( self::companion_dependencies( $adapter ) as $dependency ) {
Expand Down Expand Up @@ -519,6 +527,7 @@ private static function adapters(): array {
'slug' => 'jetpack',
'plugin_file' => 'jetpack/jetpack.php',
'availability_callback' => array( 'Static_Site_Importer_Form_Seeder', 'jetpack_forms_available' ),
'preparation_callback' => array( 'Static_Site_Importer_Form_Seeder', 'prepare_jetpack_forms_runtime' ),
'missing_apis' => array( 'Automattic\\Jetpack\\Forms\\ContactForm\\Contact_Form', 'jetpack/contact-form', 'jetpack/field-text' ),
),
),
Expand Down Expand Up @@ -791,6 +800,22 @@ static function ( array $control ): bool {
'form' => isset( $form['form'] ) && is_array( $form['form'] ) ? $form['form'] : array(),
'controls' => $controls,
);
if ( array_key_exists( 'control_topology', $form ) ) {
$topology = self::normalize_form_control_topology( $form['control_topology'], count( $controls ) );
if ( isset( $topology['error'] ) ) {
$errors[] = array( 'path' => $path_prefix . '.control_topology', 'message' => $topology['error'] );
continue;
}
$row['control_topology'] = $topology['topology'];
}
if ( array_key_exists( 'layout_graph', $form ) ) {
$graph = self::normalize_form_layout_graph( $form['layout_graph'] );
if ( isset( $graph['error'] ) ) {
$errors[] = array( 'path' => $path_prefix . '.layout_graph', 'message' => $graph['error'] );
continue;
}
$row['layout_graph'] = $graph['graph'];
}
$form_key = $row['source_path'] . "\n" . $row['selector'];
if ( isset( $seen_forms[ $form_key ] ) ) {
$errors[] = array(
Expand Down Expand Up @@ -836,6 +861,69 @@ static function ( array $control ): bool {
);
}

/** @return array{topology?:array<string,mixed>,error?:string} */
private static function normalize_form_control_topology( mixed $candidate, int $control_count ): array {
if ( ! is_array( $candidate ) || 'generic/form-control-topology/v1' !== ( $candidate['schema'] ?? null ) || true === ( $candidate['truncated'] ?? false ) || ! is_int( $candidate['max_depth'] ?? null ) || ! is_int( $candidate['max_nodes'] ?? null ) || ! is_array( $candidate['nodes'] ?? null ) || ! array_is_list( $candidate['nodes'] ) || count( $candidate['nodes'] ) > $candidate['max_nodes'] || $candidate['max_depth'] < 0 || $candidate['max_depth'] > 8 || $candidate['max_nodes'] < 1 || $candidate['max_nodes'] > 128 ) {
return array( 'error' => 'control_topology must be a complete bounded generic/form-control-topology/v1 tree.' );
}
$seen = array();
$controls = array();
$orders = array();
$nodes = array();
foreach ( $candidate['nodes'] as $node ) {
if ( ! is_array( $node ) || ! is_string( $node['id'] ?? null ) || ! preg_match( '/^(?:wrapper|control)-[0-9]+$/D', $node['id'] ) || isset( $seen[ $node['id'] ] ) || ! in_array( $node['kind'] ?? null, array( 'wrapper', 'control' ), true ) || ! is_int( $node['order'] ?? null ) || ! is_int( $node['depth'] ?? null ) || $node['order'] < 0 || $node['depth'] < 0 || $node['depth'] > $candidate['max_depth'] ) {
return array( 'error' => 'control_topology contains an invalid node.' );
}
$parent = $node['parent'] ?? null;
if ( null !== $parent && ( ! is_string( $parent ) || ! isset( $seen[ $parent ] ) || 'wrapper' !== $seen[ $parent ]['kind'] || $node['depth'] !== $seen[ $parent ]['depth'] + 1 ) ) {
return array( 'error' => 'control_topology parentage must be an ordered wrapper tree.' );
}
$key = $parent ?? '$root';
if ( isset( $orders[ $key ][ $node['order'] ] ) || ( null === $parent && 0 !== $node['depth'] ) ) {
return array( 'error' => 'control_topology sibling order or depth is invalid.' );
}
$clean = array( 'id' => $node['id'], 'kind' => $node['kind'], 'parent' => $parent, 'order' => $node['order'], 'depth' => $node['depth'] );
if ( 'control' === $node['kind'] ) {
if ( ! is_int( $node['control'] ?? null ) || $node['control'] < 0 || $node['control'] >= $control_count || isset( $controls[ $node['control'] ] ) ) {
return array( 'error' => 'control_topology control references must preserve every source control once.' );
}
$clean['control'] = $node['control'];
$controls[ $node['control'] ] = true;
} else {
foreach ( array( 'tag', 'class', 'source_id' ) as $field ) {
if ( isset( $node[ $field ] ) && ( ! is_string( $node[ $field ] ) || ! preg_match( '/^[A-Za-z][A-Za-z0-9 _-]{0,159}$/D', $node[ $field ] ) ) ) {
return array( 'error' => 'control_topology wrapper hooks must be bounded identifiers.' );
}
if ( isset( $node[ $field ] ) ) {
$clean[ $field ] = $node[ $field ];
}
}
}
$seen[ $node['id'] ] = $clean;
$orders[ $key ][ $node['order'] ] = true;
$nodes[] = $clean;
}
return count( $controls ) === $control_count ? array( 'topology' => array( 'schema' => 'generic/form-control-topology/v1', 'max_depth' => $candidate['max_depth'], 'max_nodes' => $candidate['max_nodes'], 'truncated' => false, 'nodes' => $nodes ) ) : array( 'error' => 'control_topology must preserve every source control once.' );
}

/** @return array{graph?:array<string,mixed>,error?:string} */
private static function normalize_form_layout_graph( mixed $candidate ): array {
if ( ! is_array( $candidate ) || 'generic/computed-layout-graph/v1' !== ( $candidate['schema'] ?? null ) || 'source_css_cascade' !== ( $candidate['basis'] ?? null ) || true === ( $candidate['truncated'] ?? false ) || ! is_array( $candidate['nodes'] ?? null ) || ! array_is_list( $candidate['nodes'] ) || count( $candidate['nodes'] ) > 128 || ! is_array( $candidate['variants'] ?? null ) || ! empty( $candidate['variants'] ) ) {
return array( 'error' => 'layout_graph must be a complete bounded generic/computed-layout-graph/v1 graph.' );
}
$allowed = array( 'display', 'columns', 'rows', 'gap', 'row_gap', 'column_gap', 'direction', 'wrap', 'column', 'row', 'area' );
$nodes = array();
$seen = array();
foreach ( $candidate['nodes'] as $node ) {
if ( ! is_array( $node ) || ! is_string( $node['id'] ?? null ) || ! preg_match( '/^(?:form|wrapper-[0-9]+|control-[0-9]+)$/D', $node['id'] ) || isset( $seen[ $node['id'] ] ) || ! is_array( $node['layout'] ?? null ) || array_diff( array_keys( $node['layout'] ), $allowed ) || array_filter( $node['layout'], static fn( $value ): bool => ! is_scalar( $value ) ) ) {
return array( 'error' => 'layout_graph contains unsupported layout facts.' );
}
$seen[ $node['id'] ] = true;
$nodes[] = array( 'id' => $node['id'], 'layout' => $node['layout'] );
}
return array( 'graph' => array( 'schema' => 'generic/computed-layout-graph/v1', 'basis' => 'source_css_cascade', 'truncated' => false, 'nodes' => $nodes, 'variants' => array() ) );
}

/** @return array<string,mixed>|null */
private static function normalize_block_binding( mixed $binding ): ?array {
if ( null === $binding ) {
Expand Down
117 changes: 107 additions & 10 deletions includes/class-static-site-importer-form-seeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
exit;
}

require_once __DIR__ . '/class-static-site-importer-provider-layout-overlay.php';

/**
* Turns preserved <form> fallback metadata into working Jetpack Form blocks.
*
Expand Down Expand Up @@ -145,6 +147,25 @@ public static function jetpack_forms_available(): bool {
return ! empty( $availability['available'] );
}

/** Activate and register Jetpack Forms after the plugin dependency is active. */
public static function prepare_jetpack_forms_runtime() {
if ( ! class_exists( 'Jetpack' ) || ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) {
return new WP_Error( 'static_site_importer_jetpack_forms_runtime_missing' );
}
$modules = new Automattic\Jetpack\Modules();
if ( ! $modules->is_active( 'contact-form' ) && method_exists( 'Jetpack', 'activate_module' ) ) {
Jetpack::activate_module( 'contact-form', false, false );
}
$block = 'Automattic\\Jetpack\\Extensions\\Contact_Form\\Contact_Form_Block';
if ( class_exists( $block ) && method_exists( $block, 'register_block' ) ) {
$block::register_block();
if ( method_exists( $block, 'register_child_blocks' ) ) {
$block::register_child_blocks();
}
}
return self::jetpack_forms_available() ? true : new WP_Error( 'static_site_importer_jetpack_forms_blocks_missing' );
}

/**
* Return the specific Jetpack Forms APIs present in the current runtime.
*
Expand Down Expand Up @@ -200,12 +221,13 @@ private static function seed_form( array $form, bool $available ): array {
$selector = isset( $form['selector'] ) && is_scalar( $form['selector'] ) ? (string) $form['selector'] : '';
$source_path = isset( $form['source_path'] ) && is_scalar( $form['source_path'] ) ? (string) $form['source_path'] : '';

$scope = self::layout_scope( $form );
$field_blocks = array();
$mapped_types = array();
$submit_text = 'Submit';
$skipped = array();

foreach ( $controls as $control ) {
foreach ( $controls as $control_index => $control ) {
if ( ! is_array( $control ) ) {
continue;
}
Expand All @@ -225,7 +247,8 @@ private static function seed_form( array $form, bool $available ): array {
continue;
}

$field_blocks[] = $field_block;
$field_block['attrs']['className'] = self::layout_node_class( $scope, 'control-' . $control_index );
$field_blocks[ $control_index ] = $field_block;
$mapped_types[] = $field_block['name'];
}

Expand All @@ -242,9 +265,18 @@ private static function seed_form( array $form, bool $available ): array {
);
}

$inner_blocks = $field_blocks;
$inner_blocks[] = self::submit_button_block( $submit_text );
$form_attrs = self::contact_form_attributes( $form );
$topology = self::topology_blocks( $form, $field_blocks, $controls, $scope );
if ( null === $topology ) {
return array( 'selector' => $selector, 'source_path' => $source_path, 'provider' => self::PROVIDER_ID, 'block_name' => 'jetpack/contact-form', 'status' => 'skipped', 'reason' => 'unsupported_control_topology', 'runtime_mapped' => false );
}
$inner_blocks = $topology['blocks'];
if ( ! $topology['has_submit'] ) {
$inner_blocks[] = self::submit_button_block( $submit_text, self::layout_node_class( $scope, 'control-submit' ) );
}
$target_map = self::provider_layout_target_map( $form, $scope );
$overlay = Static_Site_Importer_Provider_Layout_Overlay::compile( is_array( $form['layout_graph'] ?? null ) ? $form['layout_graph'] : array( 'nodes' => array() ), $target_map );
$receipt = array( 'schema' => 'static-site-importer/computed-layout-receipt/v1', 'status' => empty( $overlay['operations'] ) ? ( empty( $overlay['losses'] ) ? 'skipped' : 'deferred' ) : 'applied', 'operation_count' => count( $overlay['operations'] ), 'loss_count' => count( $overlay['losses'] ), 'operations' => $overlay['operations'], 'losses' => $overlay['losses'] );
$form_attrs = self::contact_form_attributes( $form, $scope );
$markup = self::serialize_block( 'jetpack/contact-form', $form_attrs, $inner_blocks );

return array(
Expand All @@ -260,9 +292,50 @@ private static function seed_form( array $form, bool $available ): array {
'runtime_mapped' => true,
'runtime_carried' => $available,
'block_markup' => $markup,
'computed_layout_receipt' => $receipt,
'provider_layout_target_map' => $target_map,
'provider_layout_overlay_css' => $overlay['overlay'],
);
}

/** @return array{blocks:array<int,array<string,mixed>>,has_submit:bool}|null */
private static function topology_blocks( array $form, array $field_blocks, array $controls, string $scope ): ?array {
if ( ! isset( $form['control_topology'] ) ) {
return array( 'blocks' => array_values( $field_blocks ), 'has_submit' => false );
}
$nodes = $form['control_topology']['nodes'] ?? null;
if ( ! is_array( $nodes ) ) {
return null;
}
$wrappers = array();
foreach ( $nodes as $node ) {
if ( is_array( $node ) && 'wrapper' === ( $node['kind'] ?? null ) && is_string( $node['id'] ?? null ) ) {
$wrappers[ $node['id'] ] = $node;
}
}
$blocks = array();
$has_submit = false;
foreach ( $nodes as $node ) {
if ( ! is_array( $node ) || 'control' !== ( $node['kind'] ?? null ) || ! is_int( $node['control'] ?? null ) || ! isset( $field_blocks[ $node['control'] ] ) ) {
continue;
}
$block = $field_blocks[ $node['control'] ];
$classes = array( self::layout_node_class( $scope, (string) $node['id'] ) );
$parent = $node['parent'] ?? null;
if ( is_string( $parent ) && isset( $wrappers[ $parent ] ) ) {
$classes[] = self::layout_node_class( $scope, $parent );
if ( ! empty( $wrappers[ $parent ]['class'] ) ) {
$classes[] = (string) $wrappers[ $parent ]['class'];
}
}
$block['attrs']['className'] = trim( (string) ( $block['attrs']['className'] ?? '' ) . ' ' . implode( ' ', $classes ) );
$blocks[] = $block;
$control = $controls[ $node['control'] ] ?? array();
$has_submit = $has_submit || ( is_array( $control ) && 'submit' === (string) ( $control['type'] ?? '' ) );
}
return count( $blocks ) === count( $field_blocks ) ? array( 'blocks' => $blocks, 'has_submit' => $has_submit ) : null;
}

/**
* Build a Jetpack field block definition from a source control.
*
Expand Down Expand Up @@ -373,12 +446,13 @@ private static function field_block_from_control( string $tag, string $type, arr
* @param string $text Submit button label.
* @return array<string, mixed>
*/
private static function submit_button_block( string $text ): array {
private static function submit_button_block( string $text, string $class_name = '' ): array {
return array(
'name' => 'jetpack/button',
'attrs' => array(
'element' => 'button',
'text' => '' !== trim( $text ) ? trim( $text ) : 'Submit',
'className' => $class_name,
'lock' => array(
'remove' => true,
'move' => false,
Expand All @@ -393,15 +467,13 @@ private static function submit_button_block( string $text ): array {
* @param array<string, mixed> $form Validated form row.
* @return array<string, mixed>
*/
private static function contact_form_attributes( array $form ): array {
private static function contact_form_attributes( array $form, string $scope = '' ): array {
$attrs = array();
$metadata = isset( $form['form'] ) && is_array( $form['form'] ) ? $form['form'] : array();
$action = isset( $metadata['action'] ) && is_scalar( $metadata['action'] ) ? trim( (string) $metadata['action'] ) : '';
$class = isset( $metadata['class'] ) && is_scalar( $metadata['class'] ) ? trim( (string) $metadata['class'] ) : '';

if ( '' !== $class ) {
$attrs['className'] = $class;
}
$attrs['className'] = trim( $class . ' ' . $scope );

if ( '' !== $action && 0 === stripos( $action, 'mailto:' ) ) {
$recipient = trim( substr( $action, 7 ) );
Expand All @@ -414,6 +486,31 @@ private static function contact_form_attributes( array $form ): array {
return $attrs;
}

private static function layout_scope( array $form ): string {
return 'ssi-form-' . substr( hash( 'sha256', (string) ( $form['source_path'] ?? '' ) . "\n" . (string) ( $form['selector'] ?? '' ) ), 0, 12 );
}

private static function layout_node_class( string $scope, string $node ): string {
return 'ssi-node-' . substr( hash( 'sha256', $scope . "\n" . $node ), 0, 12 );
}

/** @return array<string,mixed> */
private static function provider_layout_target_map( array $form, string $scope ): array {
$targets = array();
foreach ( $form['layout_graph']['nodes'] ?? array() as $node ) {
if ( ! is_array( $node ) || ! is_string( $node['id'] ?? null ) ) {
continue;
}
$id = $node['id'];
if ( 'form' === $id ) {
$targets[] = array( 'node' => $id, 'selector' => '.' . $scope . ' > form.jetpack-contact-form__form', 'capabilities' => array( 'container_layout' ) );
} elseif ( preg_match( '/^(?:control|wrapper)-[0-9]+$/D', $id ) ) {
$targets[] = array( 'node' => $id, 'selector' => '.' . $scope . ' .' . self::layout_node_class( $scope, $id ), 'capabilities' => array( 'container_layout', 'direct_child_layout', 'item_layout' ) );
}
}
return array( 'schema' => Static_Site_Importer_Provider_Layout_Overlay::MAP_SCHEMA, 'provider' => self::PROVIDER_ID, 'scope' => '.' . $scope, 'targets' => $targets );
}

/**
* Read a control label/text value.
*
Expand Down
Loading
Loading