diff --git a/README.md b/README.md index b85d1b0..996ab3c 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,24 @@ One WordPress plugin, two ways to publish a static site to Spacefast: ## Install 1. Download [`spacefast-wordpress.zip`](https://github.com/spacefast/wordpress/releases/latest/download/spacefast-wordpress.zip), then install and activate it. -2. Open **Settings → Spacefast** and choose a publishing mode. -3. Continue to Spacefast, sign in, and authorize exactly one Team. -4. Back in WordPress, choose the Space. The plugin verifies it before showing **Connected**. +2. Open **Settings → Spacefast** and select **Publish with Spacefast**. The plugin can install and activate Simply Static for you. +3. Sign in to Spacefast and authorize exactly one Team. +4. Back in WordPress, create a Space with the suggested site name or choose an existing one. The plugin publishes the first version before showing the connection as complete. OAuth access is bounded by the user's live Team role, the one Team approved on -the consent screen, and the chosen mode: static mode can publish to Spaces; -headless mode can trigger repository builds. WordPress then selects one Space +the consent screen, and the chosen mode. Static mode can create and publish a +Space. Headless mode can keep its WordPress source settings in sync and trigger +repository builds. WordPress then selects one Space inside that Team. Tokens are stored in non-autoloaded options and are never displayed. `SPACEFAST_WORDPRESS_API_URL` may override the API origin for local development. ## Static WordPress -Install and activate Simply Static, then select **Static WordPress** in -**Settings → Spacefast**. **Export and publish** starts a full Simply Static -export. Starting an export from Simply Static's own screen works too. +Select **Publish with Spacefast** in **Settings → Spacefast**. If necessary, +the same action installs and activates Simply Static. **Publish now** starts a +full Simply Static export. Starting an export from Simply Static's own screen +works too. Spacefast adds a delivery step after Simply Static has generated and delivered its files but before cleanup. The step hashes the generated bytes, declares a @@ -45,7 +47,7 @@ WordPress features still need a static-compatible replacement. ## Headless CMS -Select **Headless CMS** when an Astro or other repository project reads the +Open **Use WordPress as a CMS for a repository site** when an Astro or other repository project reads the WordPress REST API during its build. Publishing, updating, unpublishing, or deleting public REST-visible post types, taxonomy changes, navigation changes, media changes, and public user changes schedule a production build. Autosaves, @@ -56,20 +58,36 @@ Merely opening or navigating wp-admin does not schedule anything. Delivery uses WP-Cron with a stable idempotency key. Network failures, timeouts, rate limits, and server errors retry with bounded exponential backoff. A change -arriving while a delivery runs remains pending and schedules one successor after -the quiet window. **Build now** skips the window, runs immediately, and clears +arriving while a build runs remains pending and schedules exactly one successor after +the quiet window. **Build now** skips the window when no build is active and clears the superseded scheduled event. The generic **Build now** action in the Spacefast dashboard triggers the same connected production repository. -The plugin sends no post content, title, author, or other WordPress data to -Spacefast. It sends only the configured public WordPress origin and a request -to run the server-owned repository settings. +The plugin never sends drafts, credentials, or database contents to Spacefast. +It sends the public WordPress origin and can sync the public site title, +description, and search visibility. Repository source code, commands, and +publish targets remain server-owned. + +## WordPress settings sync + +WordPress owns the integration controls. By default, the plugin keeps the +Space title and sharing metadata, search-engine visibility, and—when using a +repository—the public WordPress data source in sync. Every update starts from +the current Space settings and uses the Space settings digest, preserving +unrelated dashboard and repository configuration. A concurrent dashboard edit +is reloaded and merged once; nothing is silently overwritten. + +Automatic publishing is on by default. Site and content changes share the same +60-second quiet window, and settings are synced immediately before the next +publish or build. These defaults can be changed under **WordPress sync**. ## Operate and remove -The settings page shows the active mode, pending work, delivery status, and the -last build or version ID. It also provides a connection test and appears in -Site Health. +The settings page leads with the authoritative live/publishing state, live URL, +and one Publish or Build action. Connection tests, receipts, Space changes, and +disconnect live under secondary disclosures. The integration also appears in +Site Health, and Spacefast labels versions published by this plugin as +WordPress. **Disconnect** revokes refresh access in Spacefast, removes local tokens, and clears pending work. A short-lived access token may remain valid until it diff --git a/includes/class-spacefast-client.php b/includes/class-spacefast-client.php index b664784..174c2f4 100644 --- a/includes/class-spacefast-client.php +++ b/includes/class-spacefast-client.php @@ -34,7 +34,7 @@ private function request( string $method, string $path, ?array $body = null, arr 'redirection' => 0, 'reject_unsafe_urls' => true, 'sslverify' => true, - 'limit_response_size' => 65536, + 'limit_response_size' => 1024 * 1024, 'headers' => array_merge( array( 'Authorization' => 'Bearer ' . $access_token, @@ -61,7 +61,21 @@ private function request( string $method, string $path, ?array $body = null, arr } $status = (int) wp_remote_retrieve_response_code( $response ); - $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + $raw_body = (string) wp_remote_retrieve_body( $response ); + $decoded = json_decode( $raw_body, true ); + if ( + $status >= 200 + && $status < 300 + && ( '' === $raw_body || JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) + ) { + return array( + 'ok' => false, + 'retryable' => true, + 'code' => 'invalid_response_body', + 'message' => 'Spacefast returned an unreadable response.', + 'data' => array(), + ); + } $decoded = is_array( $decoded ) ? $decoded : array(); if ( $status >= 200 && $status < 300 ) { $data = isset( $decoded['data'] ) && is_array( $decoded['data'] ) @@ -119,6 +133,25 @@ public function create_space( string $team_id, string $title, string $idempotenc ); } + /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ + public function get_space(): array { + $settings = Spacefast_Settings::get(); + return $this->request( 'GET', '/v1/spaces/' . rawurlencode( (string) $settings['space_id'] ) ); + } + + /** + * @param array $body Space settings patch. + * @return array{ok:bool,retryable:bool,code:string,message:string,data:array} + */ + public function update_space( array $body ): array { + $settings = Spacefast_Settings::get(); + return $this->request( + 'PATCH', + '/v1/spaces/' . rawurlencode( (string) $settings['space_id'] ), + $body + ); + } + /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ private function list_all( string $path ): array { $items = array(); @@ -219,6 +252,20 @@ public function trigger_build( string $event_id ): array { return $result; } + /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ + public function get_build( string $build_id ): array { + if ( ! preg_match( '/^bld_[A-Za-z0-9_-]+$/', $build_id ) ) { + return array( + 'ok' => false, + 'retryable' => false, + 'code' => 'invalid_build_id', + 'message' => 'Spacefast returned an invalid build receipt.', + 'data' => array(), + ); + } + return $this->request( 'GET', '/v1/builds/' . rawurlencode( $build_id ) ); + } + /** * @param array $files Files. * @return array{ok:bool,retryable:bool,code:string,message:string,data:array} @@ -239,6 +286,7 @@ public function create_static_version( array $files, string $event_id, string $p 'metadata' => array( 'integration' => 'wordpress', 'exporter' => 'simply-static', + 'siteUrl' => untrailingslashit( home_url() ), ), ), ), diff --git a/includes/class-spacefast-oauth.php b/includes/class-spacefast-oauth.php index fe4732b..adbfba1 100644 --- a/includes/class-spacefast-oauth.php +++ b/includes/class-spacefast-oauth.php @@ -19,7 +19,7 @@ public function __construct( ?callable $transport = null ) { public static function scopes( string $mode ): array { return Spacefast_Settings::MODE_STATIC === $mode ? array( 'teams:read', 'spaces:read', 'spaces:write', 'spaces:publish', 'offline_access' ) - : array( 'teams:read', 'spaces:read', 'builds:trigger', 'offline_access' ); + : array( 'teams:read', 'spaces:read', 'spaces:write', 'builds:trigger', 'offline_access' ); } public static function has_scope( string $scope ): bool { @@ -73,6 +73,7 @@ public function begin( string $mode ): array { $callback = self::callback_url(); $scopes = implode( ' ', self::scopes( $mode ) ); $resource = $api_url . self::RESOURCE_PATH; + $current = Spacefast_Settings::get(); $registration = $this->json_request( 'POST', $api_url . '/v1/auth/oauth2/register', @@ -101,6 +102,18 @@ public function begin( string $mode ): array { 'client_id' => $client_id, 'mode' => $mode, 'callback' => $callback, + 'resume_connection' => Spacefast_Settings::configured() && $mode === $current['mode'] + ? array( + 'team_id' => $current['team_id'], + 'team_name' => $current['team_name'], + 'team_slug' => $current['team_slug'], + 'space_id' => $current['space_id'], + 'space_name' => $current['space_name'], + 'space_slug' => $current['space_slug'], + 'live_url' => $current['live_url'], + 'verified_at' => $current['verified_at'], + ) + : null, 'expires_at' => time() + 10 * MINUTE_IN_SECONDS, ), false @@ -122,7 +135,7 @@ public function begin( string $mode ): array { return array( 'ok' => true, 'message' => '', 'url' => $url ); } - /** @return array{ok:bool,message:string} */ + /** @return array{ok:bool,message:string,resumed?:bool} */ public function finish( string $code, string $state ): array { $pending = get_option( self::PENDING_OPTION, array() ); delete_option( self::PENDING_OPTION ); @@ -161,7 +174,27 @@ public function finish( string $code, string $state ): array { 'team_id' => '', 'space_id' => '', 'verified_at' => 0, ) ); - return $this->load_choices(); + $choices_result = $this->load_choices(); + if ( ! $choices_result['ok'] ) return $choices_result; + $resume = isset( $pending['resume_connection'] ) && is_array( $pending['resume_connection'] ) + ? $pending['resume_connection'] + : null; + $choices = get_option( self::CHOICES_OPTION, array() ); + $spaces = is_array( $choices['spaces'] ?? null ) ? $choices['spaces'] : array(); + $resume_space_id = is_array( $resume ) ? (string) ( $resume['space_id'] ?? '' ) : ''; + $available = false; + foreach ( $spaces as $space ) { + if ( is_array( $space ) && '' !== $resume_space_id && hash_equals( (string) ( $space['id'] ?? '' ), $resume_space_id ) ) { + $available = true; + break; + } + } + if ( $available && is_array( $resume ) ) { + Spacefast_Settings::merge( $resume ); + delete_option( self::CHOICES_OPTION ); + return array( 'ok' => true, 'message' => '', 'resumed' => true ); + } + return array( 'ok' => true, 'message' => '', 'resumed' => false ); } /** @return array{ok:bool,message:string} */ diff --git a/includes/class-spacefast-plugin.php b/includes/class-spacefast-plugin.php index d3f07c5..43a0772 100644 --- a/includes/class-spacefast-plugin.php +++ b/includes/class-spacefast-plugin.php @@ -4,6 +4,7 @@ final class Spacefast_Plugin { const AUTOMATIC_DEBOUNCE_SECONDS = MINUTE_IN_SECONDS; + const STATIC_DELIVERY_STALE_SECONDS = 2 * HOUR_IN_SECONDS; const LOCK_OPTION = 'spacefast_wordpress_worker_lock'; const NOTICE_TRANSIENT = 'spacefast_wordpress_admin_notice'; @@ -16,11 +17,14 @@ public static function boot(): void { add_action( 'admin_post_spacefast_wordpress_oauth_start', array( __CLASS__, 'oauth_start' ) ); add_action( 'admin_post_spacefast_wordpress_select_space', array( __CLASS__, 'select_space' ) ); add_action( 'admin_post_spacefast_wordpress_create_space', array( __CLASS__, 'create_space' ) ); + add_action( 'admin_post_spacefast_wordpress_prepare_static', array( __CLASS__, 'prepare_static' ) ); add_action( 'admin_post_spacefast_wordpress_change_space', array( __CLASS__, 'change_space' ) ); add_action( 'admin_post_spacefast_wordpress_disconnect', array( __CLASS__, 'disconnect' ) ); add_action( 'admin_post_spacefast_wordpress_test', array( __CLASS__, 'test_connection' ) ); add_action( 'admin_post_spacefast_wordpress_build', array( __CLASS__, 'manual_build' ) ); add_action( 'admin_post_spacefast_wordpress_publish', array( __CLASS__, 'manual_static_publish' ) ); + add_action( 'admin_post_spacefast_wordpress_save_settings', array( __CLASS__, 'save_settings' ) ); + add_action( 'admin_post_spacefast_wordpress_sync_settings', array( __CLASS__, 'manual_settings_sync' ) ); add_action( Spacefast_Sync_State::HOOK, array( __CLASS__, 'deliver' ) ); add_action( 'init', array( __CLASS__, 'register_simply_static' ), 20 ); add_action( 'transition_post_status', array( __CLASS__, 'post_transition' ), 10, 3 ); @@ -36,6 +40,10 @@ public static function boot(): void { add_action( 'profile_update', array( __CLASS__, 'user_changed' ) ); add_action( 'user_register', array( __CLASS__, 'user_changed' ) ); add_action( 'deleted_user', array( __CLASS__, 'user_changed' ) ); + add_action( 'update_option_blogname', array( __CLASS__, 'wordpress_setting_changed' ), 10, 3 ); + add_action( 'update_option_blogdescription', array( __CLASS__, 'wordpress_setting_changed' ), 10, 3 ); + add_action( 'update_option_blog_public', array( __CLASS__, 'wordpress_setting_changed' ), 10, 3 ); + add_action( 'update_option_home', array( __CLASS__, 'wordpress_setting_changed' ), 10, 3 ); add_filter( 'site_status_tests', array( __CLASS__, 'site_health_tests' ) ); } @@ -49,7 +57,12 @@ public static function activate( bool $network_wide = false ): void { add_option( Spacefast_Sync_State::OPTION, Spacefast_Sync_State::defaults(), '', false ); } $state = Spacefast_Sync_State::get(); - if ( (int) $state['desired'] > (int) $state['delivered'] ) { + $active_delivery = 'building' === (string) $state['last_status'] + && '' !== (string) $state['last_build_id']; + $active_export = self::static_delivery_active( $state ); + if ( $active_delivery || $active_export ) { + self::schedule( time() + 1 ); + } elseif ( (int) $state['desired'] > (int) $state['delivered'] ) { self::schedule_after_change( (int) $state['last_change_at'] ); } } @@ -106,6 +119,24 @@ public static function oauth_start(): void { } } + public static function prepare_static(): void { + self::assert_admin( 'spacefast_wordpress_prepare_static' ); + $result = self::install_simply_static(); + if ( ! $result['ok'] ) { + self::notice( 'error', $result['message'] ); + self::redirect(); + } + try { + $oauth = ( new Spacefast_OAuth() )->begin( Spacefast_Settings::MODE_STATIC ); + if ( ! $oauth['ok'] ) throw new RuntimeException( $oauth['message'] ); + wp_redirect( esc_url_raw( (string) $oauth['url'] ) ); + exit; + } catch ( Throwable $error ) { + self::notice( 'error', $error->getMessage() ); + self::redirect(); + } + } + public static function oauth_callback(): void { if ( 'callback' !== ( $_GET['spacefast_oauth'] ?? '' ) ) return; if ( ! current_user_can( 'manage_options' ) ) { @@ -118,6 +149,17 @@ public static function oauth_callback(): void { $code = sanitize_text_field( wp_unslash( $_GET['code'] ?? '' ) ); $state = sanitize_text_field( wp_unslash( $_GET['state'] ?? '' ) ); $result = ( new Spacefast_OAuth() )->finish( $code, $state ); + if ( $result['ok'] && ! empty( $result['resumed'] ) ) { + self::mark_settings_pending(); + $sync = self::sync_space_settings(); + self::notice( + $sync['ok'] ? 'success' : 'error', + $sync['ok'] + ? __( 'Spacefast reconnected and WordPress settings are in sync.', 'spacefast-wordpress' ) + : $sync['message'] + ); + self::redirect(); + } self::notice( $result['ok'] ? 'success' : 'error', $result['ok'] @@ -153,7 +195,7 @@ public static function select_space(): void { $result = self::connect_space( $space, is_array( $team ) ? $team : array() ); self::notice( $result['ok'] ? 'success' : 'error', - $result['ok'] ? __( 'Spacefast is connected.', 'spacefast-wordpress' ) : $result['message'] + $result['ok'] ? self::setup_started_message() : $result['message'] ); self::redirect(); } @@ -213,7 +255,7 @@ public static function create_space(): void { $result = self::connect_space( $space, $team ); self::notice( $result['ok'] ? 'success' : 'error', - $result['ok'] ? __( 'Space created and connected.', 'spacefast-wordpress' ) : $result['message'] + $result['ok'] ? self::setup_started_message() : $result['message'] ); self::redirect(); } @@ -224,6 +266,13 @@ public static function create_space(): void { * @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ private static function connect_space( array $space, array $team ): array { + $current_settings = Spacefast_Settings::get(); + if ( ! hash_equals( (string) $current_settings['space_id'], (string) $space['id'] ) ) { + wp_clear_scheduled_hook( Spacefast_Sync_State::HOOK ); + Spacefast_Sync_State::save( Spacefast_Sync_State::defaults() ); + Spacefast_Static_Publisher::reset(); + self::$change_recorded = false; + } Spacefast_Settings::merge( array( 'team_id' => (string) ( $space['teamId'] ?? $team['id'] ?? '' ), @@ -236,14 +285,36 @@ private static function connect_space( array $space, array $team ): array { 'verified_at' => 0, ) ); + self::mark_settings_pending(); + $settings_sync = self::sync_space_settings(); + if ( ! $settings_sync['ok'] && 'reauthorization_required' !== $settings_sync['code'] ) { + self::static_publish_failed( $settings_sync['message'] ); + return $settings_sync; + } $result = ( new Spacefast_Client() )->health(); - if ( $result['ok'] ) { - Spacefast_Settings::merge( array( 'verified_at' => time() ) ); - delete_option( Spacefast_OAuth::CHOICES_OPTION ); - delete_option( Spacefast_OAuth::CREATION_OPTION ); - self::configure_simply_static(); + if ( ! $result['ok'] ) { + self::static_publish_failed( $result['message'] ); + return $result; } - return $result; + delete_option( Spacefast_OAuth::CHOICES_OPTION ); + delete_option( Spacefast_OAuth::CREATION_OPTION ); + self::configure_simply_static(); + self::record_change( 'first_publish', true ); + wp_clear_scheduled_hook( Spacefast_Sync_State::HOOK ); + self::deliver(); + return array( + 'ok' => true, + 'retryable' => false, + 'code' => 'setup_started', + 'message' => '', + 'data' => array(), + ); + } + + private static function setup_started_message(): string { + return Spacefast_Settings::MODE_STATIC === Spacefast_Settings::mode() + ? __( 'Generating and publishing the first version now.', 'spacefast-wordpress' ) + : __( 'Space selected. Building the first version now.', 'spacefast-wordpress' ); } public static function change_space(): void { @@ -297,7 +368,7 @@ public static function manual_build(): void { self::notice( 'error', __( 'Switch to Headless CMS to trigger repository builds.', 'spacefast-wordpress' ) ); self::redirect(); } - self::record_change( 'manual' ); + self::record_change( 'manual', true ); wp_clear_scheduled_hook( Spacefast_Sync_State::HOOK ); self::deliver(); self::redirect(); @@ -313,26 +384,185 @@ public static function manual_static_publish(): void { self::notice( 'error', __( 'Install and activate Simply Static before publishing.', 'spacefast-wordpress' ) ); self::redirect(); } - try { - $started = \Simply_Static\Plugin::instance()->run_static_export(); - self::notice( - $started ? 'success' : 'error', - $started - ? __( 'Static export started. Simply Static will publish it to Spacefast.', 'spacefast-wordpress' ) - : __( 'Simply Static is already running or could not start the export.', 'spacefast-wordpress' ) + $before = Spacefast_Sync_State::get(); + $was_active = self::static_delivery_active( $before ); + $previous_attempt = (int) $before['last_attempt_at']; + self::record_change( 'manual', true ); + wp_clear_scheduled_hook( Spacefast_Sync_State::HOOK ); + self::deliver(); + $state = Spacefast_Sync_State::get(); + $active = self::static_delivery_active( $state ); + $started = $active && ( ! $was_active || (int) $state['last_attempt_at'] > $previous_attempt ); + $message = $started + ? __( 'Publishing started. You can leave this page.', 'spacefast-wordpress' ) + : ( $active + ? __( 'A publish is already running. Your latest changes will publish next.', 'spacefast-wordpress' ) + : ( (string) $state['last_message'] ?: __( 'The publish could not start. The current live version is safe.', 'spacefast-wordpress' ) ) ); - } catch ( Throwable $error ) { - self::notice( 'error', $error->getMessage() ); - } + self::notice( + $active ? 'success' : 'error', + $message + ); + self::redirect(); + } + + public static function save_settings(): void { + self::assert_admin( 'spacefast_wordpress_save_settings' ); + Spacefast_Settings::merge( + array( + 'automatic_sync' => isset( $_POST['automatic_sync'] ), + 'sync_title' => isset( $_POST['sync_title'] ), + 'sync_visibility' => isset( $_POST['sync_visibility'] ), + 'sync_source' => isset( $_POST['sync_source'] ), + ) + ); + self::mark_settings_pending(); + $result = self::sync_space_settings(); + self::notice( + $result['ok'] ? 'success' : 'error', + $result['ok'] + ? __( 'WordPress settings synced to the Space.', 'spacefast-wordpress' ) + : $result['message'] + ); + self::redirect(); + } + + public static function manual_settings_sync(): void { + self::assert_admin( 'spacefast_wordpress_sync_settings' ); + self::mark_settings_pending(); + $result = self::sync_space_settings(); + self::notice( + $result['ok'] ? 'success' : 'error', + $result['ok'] + ? __( 'WordPress settings synced to the Space.', 'spacefast-wordpress' ) + : $result['message'] + ); self::redirect(); } + public static function wordpress_setting_changed(): void { + $settings = Spacefast_Settings::get(); + if ( empty( $settings['automatic_sync'] ) || ! Spacefast_Settings::selected() ) return; + if ( ! Spacefast_OAuth::has_scope( 'spaces:write' ) ) { + self::record_change( 'settings' ); + return; + } + self::mark_settings_pending(); + self::record_change( 'settings' ); + } + + private static function mark_settings_pending(): void { + Spacefast_Sync_State::mutate( + static function ( array $state ): array { + $state['settings_pending'] = true; + return $state; + } + ); + } + + /** @return array{ok:bool,retryable:bool,code:string,message:string,data:array} */ + private static function sync_space_settings(): array { + if ( ! Spacefast_Settings::selected() ) { + return array( + 'ok' => false, + 'retryable' => false, + 'code' => 'space_required', + 'message' => __( 'Choose a Space before syncing settings.', 'spacefast-wordpress' ), + 'data' => array(), + ); + } + if ( ! Spacefast_OAuth::has_scope( 'spaces:write' ) ) { + return array( + 'ok' => false, + 'retryable' => false, + 'code' => 'reauthorization_required', + 'message' => __( 'Reconnect Spacefast to sync WordPress settings.', 'spacefast-wordpress' ), + 'data' => array(), + ); + } + $client = new Spacefast_Client(); + for ( $attempt = 0; $attempt < 2; $attempt++ ) { + $current = $client->get_space(); + if ( ! $current['ok'] ) return $current; + $space = $current['data']; + $body = Spacefast_Site_Settings::patch( + $space, + Spacefast_Settings::get(), + Spacefast_Site_Settings::snapshot(), + Spacefast_Settings::mode() + ); + $result = $client->update_space( $body ); + if ( ! $result['ok'] && 'name_managed_by_config' === $result['code'] && isset( $body['title'] ) ) { + unset( $body['title'] ); + $result = $client->update_space( $body ); + } + if ( $result['ok'] ) { + $updated = $result['data']; + Spacefast_Settings::merge( + array( + 'space_name' => (string) ( $updated['title'] ?? Spacefast_Settings::get()['space_name'] ), + 'live_url' => (string) ( $updated['liveUrl'] ?? Spacefast_Settings::get()['live_url'] ), + 'last_settings_sync_at' => time(), + ) + ); + Spacefast_Sync_State::mutate( + static fn( array $state ): array => Spacefast_Sync_State::acknowledge_settings_sync( $state ) + ); + return $result; + } + if ( 'publish_base_changed' !== $result['code'] ) return $result; + } + return array( + 'ok' => false, + 'retryable' => true, + 'code' => 'publish_base_changed', + 'message' => __( 'Space settings changed at the same time. Nothing was overwritten; try again.', 'spacefast-wordpress' ), + 'data' => array(), + ); + } + + /** @return array{ok:bool,message:string} */ + private static function install_simply_static(): array { + if ( self::simply_static_available() ) return array( 'ok' => true, 'message' => '' ); + $plugin = 'simply-static/simply-static.php'; + if ( ! function_exists( 'is_plugin_active' ) ) require_once ABSPATH . 'wp-admin/includes/plugin.php'; + if ( ! file_exists( WP_PLUGIN_DIR . '/' . $plugin ) ) { + if ( ! current_user_can( 'install_plugins' ) ) { + return array( 'ok' => false, 'message' => __( 'Ask an administrator to install Simply Static.', 'spacefast-wordpress' ) ); + } + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + $info = plugins_api( 'plugin_information', array( 'slug' => 'simply-static', 'fields' => array( 'sections' => false ) ) ); + if ( is_wp_error( $info ) || ! is_object( $info ) || empty( $info->download_link ) ) { + return array( 'ok' => false, 'message' => __( 'Simply Static could not be downloaded. Try again from Plugins.', 'spacefast-wordpress' ) ); + } + $installed = ( new Plugin_Upgrader( new Automatic_Upgrader_Skin() ) )->install( $info->download_link ); + if ( true !== $installed ) { + return array( 'ok' => false, 'message' => __( 'Simply Static could not be installed. Try again from Plugins.', 'spacefast-wordpress' ) ); + } + } + if ( ! is_plugin_active( $plugin ) ) { + if ( ! current_user_can( 'activate_plugins' ) ) { + return array( 'ok' => false, 'message' => __( 'Ask an administrator to activate Simply Static.', 'spacefast-wordpress' ) ); + } + $error = activate_plugin( $plugin ); + if ( is_wp_error( $error ) ) { + return array( 'ok' => false, 'message' => __( 'Simply Static could not be activated. Try again from Plugins.', 'spacefast-wordpress' ) ); + } + } + return array( 'ok' => true, 'message' => '' ); + } + public static function post_transition( string $new_status, string $old_status, WP_Post $post ): void { if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) { return; } $post_type = get_post_type_object( $post->post_type ); - if ( ! $post_type || ! $post_type->public || ! $post_type->show_in_rest ) { + if ( + ! $post_type + || ! $post_type->public + || ( Spacefast_Settings::MODE_HEADLESS === Spacefast_Settings::mode() && ! $post_type->show_in_rest ) + ) { return; } if ( 'publish' !== $new_status && 'publish' !== $old_status ) { @@ -344,7 +574,11 @@ public static function post_transition( string $new_status, string $old_status, public static function post_deleted( int $post_id, WP_Post $post ): void { unset( $post_id ); $post_type = get_post_type_object( $post->post_type ); - if ( ! $post_type || ! $post_type->public || ! $post_type->show_in_rest ) { + if ( + ! $post_type + || ! $post_type->public + || ( Spacefast_Settings::MODE_HEADLESS === Spacefast_Settings::mode() && ! $post_type->show_in_rest ) + ) { return; } if ( 'publish' === $post->post_status ) { @@ -375,11 +609,11 @@ public static function user_changed(): void { self::record_change( 'user' ); } - private static function record_change( string $reason ): void { + private static function record_change( string $reason, bool $force = false ): void { if ( self::$change_recorded - || ! Spacefast_Settings::configured() - || Spacefast_Settings::MODE_HEADLESS !== Spacefast_Settings::mode() + || ! Spacefast_Settings::selected() + || ( ! $force && empty( Spacefast_Settings::get()['automatic_sync'] ) ) ) { return; } @@ -419,12 +653,7 @@ private static function schedule( int $timestamp ): void { } public static function deliver(): void { - if ( - ! Spacefast_Settings::configured() - || Spacefast_Settings::MODE_HEADLESS !== Spacefast_Settings::mode() - ) { - return; - } + if ( ! Spacefast_Settings::selected() ) return; $now = time(); $owner = (string) wp_generate_uuid4(); if ( ! Spacefast_Sync_State::claim_lock( self::LOCK_OPTION, $owner, $now ) ) { @@ -434,6 +663,29 @@ public static function deliver(): void { try { $snapshot = Spacefast_Sync_State::get(); + if ( ! empty( $snapshot['settings_pending'] ) && Spacefast_OAuth::has_scope( 'spaces:write' ) ) { + $settings_sync = self::sync_space_settings(); + if ( ! $settings_sync['ok'] ) { + Spacefast_Sync_State::mutate( + static function ( array $state ) use ( $settings_sync ): array { + $state['last_status'] = 'blocked'; + $state['last_message'] = $settings_sync['message']; + return $state; + } + ); + if ( $settings_sync['retryable'] ) self::schedule( time() + 60 ); + return; + } + $snapshot = Spacefast_Sync_State::get(); + } + if ( Spacefast_Settings::MODE_STATIC === Spacefast_Settings::mode() ) { + self::deliver_static( $snapshot ); + return; + } + if ( 'building' === (string) $snapshot['last_status'] && '' !== (string) $snapshot['last_build_id'] ) { + self::poll_headless_build( $snapshot ); + return; + } $generation = (int) $snapshot['desired']; if ( $generation <= (int) $snapshot['delivered'] || '' === $snapshot['event_id'] ) { return; @@ -457,9 +709,7 @@ static function ( array $state ): array { (string) $build['id'] ) ); - if ( (int) $current['desired'] > (int) $current['delivered'] ) { - self::schedule_after_change( (int) $current['last_change_at'] ); - } + self::schedule( time() + 15 ); return; } @@ -484,6 +734,102 @@ static function ( array $state ) use ( $result ): array { } } + /** @param array $snapshot Current delivery state. */ + private static function poll_headless_build( array $snapshot ): void { + $result = ( new Spacefast_Client() )->get_build( (string) $snapshot['last_build_id'] ); + if ( ! $result['ok'] ) { + if ( $result['retryable'] ) self::schedule( time() + 60 ); + else self::static_publish_failed( $result['message'] ); + return; + } + $build = $result['data']; + $status = (string) ( $build['status'] ?? '' ); + if ( in_array( $status, array( 'queued', 'running', 'waiting_for_source', 'uploading_source' ), true ) ) { + self::schedule( time() + 15 ); + return; + } + if ( ! in_array( $status, array( 'succeeded', 'failed', 'canceled', 'skipped' ), true ) ) { + self::schedule( time() + 60 ); + return; + } + $diagnostics = isset( $build['diagnostics'] ) && is_array( $build['diagnostics'] ) ? $build['diagnostics'] : array(); + $message = ''; + foreach ( $diagnostics as $diagnostic ) { + if ( is_array( $diagnostic ) && 'error' === ( $diagnostic['severity'] ?? '' ) ) { + $message = sanitize_text_field( (string) ( $diagnostic['message'] ?? '' ) ); + break; + } + } + $current = Spacefast_Sync_State::mutate( + static fn( array $state ): array => Spacefast_Sync_State::acknowledge_build_terminal( $state, $status, $message ) + ); + if ( 'succeeded' === $status ) { + Spacefast_Settings::merge( array( 'verified_at' => time() ) ); + } + if ( (int) $current['desired'] > (int) $current['delivered'] ) { + self::schedule_after_change( (int) $current['last_change_at'] ); + } + } + + /** @param array $snapshot Current delivery state. */ + private static function deliver_static( array $snapshot ): void { + if ( self::static_delivery_active( $snapshot ) ) { + if ( ! self::static_delivery_stale( $snapshot ) ) { + self::schedule( max( time() + 60, (int) $snapshot['last_attempt_at'] + self::STATIC_DELIVERY_STALE_SECONDS ) ); + return; + } + Spacefast_Static_Publisher::reset(); + $snapshot = Spacefast_Sync_State::mutate( + static function ( array $state ): array { + $state['last_status'] = 'pending'; + $state['last_message'] = ''; + $state['active_generation'] = 0; + return $state; + } + ); + } + if ( (int) $snapshot['desired'] <= (int) $snapshot['delivered'] ) return; + if ( ! self::simply_static_available() ) { + self::static_publish_failed( __( 'Simply Static is required. Install it, then retry; the current live version is safe.', 'spacefast-wordpress' ) ); + return; + } + self::configure_simply_static(); + try { + Spacefast_Sync_State::mutate( + static function ( array $state ): array { + $state['last_attempt_at'] = time(); + return $state; + } + ); + $started = \Simply_Static\Plugin::instance()->run_static_export(); + if ( ! $started ) { + self::schedule( time() + 60 ); + return; + } + Spacefast_Sync_State::mutate( + static function ( array $state ): array { + $state['last_status'] = 'exporting'; + $state['last_message'] = ''; + $state['last_attempt_at'] = time(); + return $state; + } + ); + } catch ( Throwable $error ) { + self::static_publish_failed( $error->getMessage() ); + } + } + + /** @param array $state Current delivery state. */ + private static function static_delivery_active( array $state ): bool { + return in_array( (string) $state['last_status'], array( 'exporting', 'uploading', 'finalizing' ), true ); + } + + /** @param array $state Current delivery state. */ + private static function static_delivery_stale( array $state ): bool { + $last_attempt = (int) $state['last_attempt_at']; + return 0 === $last_attempt || $last_attempt + self::STATIC_DELIVERY_STALE_SECONDS <= time(); + } + public static function register_simply_static(): void { if ( ! self::simply_static_available() ) { return; @@ -522,7 +868,7 @@ public static function simply_static_tasks( array $tasks, string $delivery_metho unset( $delivery_method ); if ( Spacefast_Settings::MODE_STATIC !== Spacefast_Settings::mode() - || ! Spacefast_Settings::configured() + || ! Spacefast_Settings::selected() || in_array( 'spacefast_publish', $tasks, true ) ) { return $tasks; @@ -543,7 +889,7 @@ public static function static_export_started( int $blog_id = 0 ): void { unset( $blog_id ); if ( Spacefast_Settings::MODE_STATIC !== Spacefast_Settings::mode() - || ! Spacefast_Settings::configured() + || ! Spacefast_Settings::selected() ) { return; } @@ -556,19 +902,36 @@ static function ( array $state ): array { (string) wp_generate_uuid4() ); $next['last_status'] = 'exporting'; + $next['active_generation'] = (int) $next['desired']; + $next['last_attempt_at'] = time(); return $next; } ); } - public static function static_publish_completed( string $version_id, string $status ): void { + public static function static_publish_progress( string $status ): void { + if ( ! in_array( $status, array( 'uploading', 'finalizing' ), true ) ) return; Spacefast_Sync_State::mutate( + static function ( array $state ) use ( $status ): array { + $state['last_status'] = $status; + $state['last_attempt_at'] = time(); + return $state; + } + ); + } + + public static function static_publish_completed( string $version_id, string $status ): void { + $current = Spacefast_Sync_State::mutate( static fn( array $state ): array => Spacefast_Sync_State::acknowledge_static( $state, $version_id, $status ) ); + if ( 'live' === $status ) Spacefast_Settings::merge( array( 'verified_at' => time() ) ); + if ( (int) $current['desired'] > (int) $current['delivered'] ) { + self::schedule_after_change( (int) $current['last_change_at'] ); + } } public static function static_publish_failed( string $message ): void { @@ -576,6 +939,7 @@ public static function static_publish_failed( string $message ): void { static function ( array $state ) use ( $message ): array { $state['last_status'] = 'blocked'; $state['last_message'] = $message; + $state['active_generation'] = 0; return $state; } ); @@ -628,7 +992,7 @@ public static function status_label( string $status, bool $static_mode ): string $labels = array( 'idle' => __( 'Ready', 'spacefast-wordpress' ), 'pending' => $static_mode ? __( 'Waiting for export', 'spacefast-wordpress' ) : __( 'Changes waiting to build', 'spacefast-wordpress' ), - 'delivered' => __( 'Build started', 'spacefast-wordpress' ), + 'building' => __( 'Building the new version', 'spacefast-wordpress' ), 'exporting' => __( 'Generating static site', 'spacefast-wordpress' ), 'uploading' => __( 'Publishing files', 'spacefast-wordpress' ), 'finalizing' => __( 'Activating new version', 'spacefast-wordpress' ), @@ -645,15 +1009,17 @@ public static function status_label( string $status, bool $static_mode ): string * @return array */ public static function site_health_result(): array { - $result = Spacefast_Settings::configured() + $connected = Spacefast_Settings::configured(); + $selected = Spacefast_Settings::selected(); + $result = $selected ? ( new Spacefast_Client() )->health() : array( 'ok' => false ); - $healthy = true === ( $result['ok'] ?? false ); + $healthy = $connected && true === ( $result['ok'] ?? false ); return array( 'label' => $healthy ? __( 'Spacefast is connected', 'spacefast-wordpress' ) - : __( 'Spacefast needs attention', 'spacefast-wordpress' ), - 'status' => $healthy ? 'good' : 'critical', + : ( $selected ? __( 'Spacefast is finishing setup', 'spacefast-wordpress' ) : __( 'Spacefast needs attention', 'spacefast-wordpress' ) ), + 'status' => $healthy ? 'good' : ( $selected ? 'recommended' : 'critical' ), 'badge' => array( 'label' => 'Spacefast', 'color' => 'blue', @@ -663,7 +1029,9 @@ public static function site_health_result(): array { ? ( Spacefast_Settings::MODE_STATIC === Spacefast_Settings::mode() ? __( 'Simply Static exports can publish to this Space.', 'spacefast-wordpress' ) : __( 'Public content changes can trigger repository builds.', 'spacefast-wordpress' ) ) - : __( 'Open Settings → Spacefast to repair the connection.', 'spacefast-wordpress' ) + : ( $selected + ? __( 'The first publish or build has not completed yet. The previous live version, if any, is safe.', 'spacefast-wordpress' ) + : __( 'Open Settings → Spacefast to repair the connection.', 'spacefast-wordpress' ) ) ) . '

', 'test' => 'spacefast_connection', ); @@ -678,6 +1046,7 @@ public static function render_admin(): void { $state = Spacefast_Sync_State::get(); $settings = Spacefast_Settings::get(); $authorized = Spacefast_Settings::authorized(); + $selected = Spacefast_Settings::selected(); $configured = Spacefast_Settings::configured(); $mode = Spacefast_Settings::mode(); $static_mode = Spacefast_Settings::MODE_STATIC === $mode; @@ -685,29 +1054,54 @@ public static function render_admin(): void { ?>

-

+

-

-
- - -
- - -
-

-

- -
- -

+
+

+

+

+ + + + + +

+ +
+ +

+
+

+ +
+ +

+ + +
+

+
+ + +
+ + +
+
+ +

+ +

+ + -
+
+
@@ -717,58 +1111,53 @@ public static function render_admin(): void { - + +

- - -

-

-
- - -
- - -
- -

- -

- - -

— /

-

.

-

- - - - - -
+
+

+

·

+

+

+

+

+
+ + +
+
+

+ +

+ +
+ + + + + + + +
+

-
- - - - - - - -
+ +
+

@@ -788,6 +1177,23 @@ private static function simply_static_action_link(): string { return '' . esc_html__( 'Install Simply Static', 'spacefast-wordpress' ) . ''; } + /** @return array */ + private static function preflight_issues(): array { + $issues = array(); + $parts = wp_parse_url( home_url() ); + if ( ! is_array( $parts ) || 'https' !== strtolower( (string) ( $parts['scheme'] ?? '' ) ) || empty( $parts['host'] ) ) { + $issues[] = __( 'Use a public HTTPS WordPress address before connecting.', 'spacefast-wordpress' ); + } + $uploads = wp_upload_dir(); + if ( ! empty( $uploads['error'] ) || empty( $uploads['basedir'] ) || ! is_writable( (string) $uploads['basedir'] ) ) { + $issues[] = __( 'Make the WordPress uploads directory writable so the static export can be generated.', 'spacefast-wordpress' ); + } + if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) { + $issues[] = __( 'WP-Cron is disabled. Configure a server cron for wp-cron.php so automatic publishing keeps running.', 'spacefast-wordpress' ); + } + return $issues; + } + private static function action_form( string $action, string $label, string $class ): void { ?>
@@ -798,13 +1204,13 @@ private static function action_form( string $action, string $label, string $clas - +
'', 'live_url' => '', 'verified_at' => 0, + 'automatic_sync' => true, + 'sync_title' => true, + 'sync_visibility' => true, + 'sync_source' => true, + 'last_settings_sync_at' => 0, ), $stored, array( 'api_url' => self::api_url(), 'mode' => $mode ) @@ -88,6 +93,13 @@ public static function configured(): bool { && 0 < (int) $value['verified_at']; } + public static function selected(): bool { + $value = self::get(); + return self::authorized() + && '' !== $value['team_id'] + && '' !== $value['space_id']; + } + public static function disconnect(): void { delete_option( self::OPTION ); delete_option( Spacefast_OAuth::PENDING_OPTION ); diff --git a/includes/class-spacefast-simply-static-task.php b/includes/class-spacefast-simply-static-task.php index 64931d4..769bbd8 100644 --- a/includes/class-spacefast-simply-static-task.php +++ b/includes/class-spacefast-simply-static-task.php @@ -29,6 +29,7 @@ public function perform() { $partial_export ? 'additive' : 'snapshot' ); if ( ! $result['done'] ) { + Spacefast_Plugin::static_publish_progress( $result['status'] ); $this->save_status_message( 'finalizing' === $result['status'] ? __( 'Spacefast is activating the uploaded version', 'spacefast-wordpress' ) diff --git a/includes/class-spacefast-site-settings.php b/includes/class-spacefast-site-settings.php new file mode 100644 index 0000000..20228d6 --- /dev/null +++ b/includes/class-spacefast-site-settings.php @@ -0,0 +1,73 @@ + self::limit( sanitize_text_field( (string) get_bloginfo( 'name' ) ), 255 ), + 'description' => self::limit( sanitize_text_field( (string) get_bloginfo( 'description' ) ), 1000 ), + 'noindex' => '0' === (string) get_option( 'blog_public', '1' ), + 'source_url' => untrailingslashit( home_url() ), + ); + } + + /** + * @param array $space Current Space resource. + * @param array $preferences WordPress sync preferences. + * @param array{title:string,description:string,noindex:bool,source_url:string} $snapshot WordPress values. + * @return array + */ + public static function patch( array $space, array $preferences, array $snapshot, string $mode ): array { + $config = isset( $space['config'] ) && is_array( $space['config'] ) ? $space['config'] : array(); + $body = array(); + + if ( ! empty( $preferences['sync_title'] ) ) { + if ( '' !== $snapshot['title'] ) { + $body['title'] = $snapshot['title']; + $config['meta'] = isset( $config['meta'] ) && is_array( $config['meta'] ) ? $config['meta'] : array(); + $config['meta']['title'] = self::limit( $snapshot['title'], 300 ); + } + if ( '' !== $snapshot['description'] ) { + $config['meta'] = isset( $config['meta'] ) && is_array( $config['meta'] ) ? $config['meta'] : array(); + $config['meta']['description'] = $snapshot['description']; + } elseif ( isset( $config['meta'] ) && is_array( $config['meta'] ) ) { + unset( $config['meta']['description'] ); + if ( array() === $config['meta'] ) unset( $config['meta'] ); + } + } + + if ( ! empty( $preferences['sync_visibility'] ) ) { + $body['noindex'] = $snapshot['noindex']; + } + + if ( Spacefast_Settings::MODE_HEADLESS === $mode && ! empty( $preferences['sync_source'] ) ) { + $config['dataSources'] = isset( $config['dataSources'] ) && is_array( $config['dataSources'] ) + ? $config['dataSources'] + : array(); + $config['dataSources']['wordpress'] = array( + 'kind' => 'wordpress', + 'url' => $snapshot['source_url'], + ); + $config['defaultDataSource'] = 'wordpress'; + } + + $body['config'] = $config; + if ( isset( $space['settingsDigest'] ) && is_string( $space['settingsDigest'] ) ) { + $body['baseSettingsDigest'] = $space['settingsDigest']; + } + return $body; + } + + private static function limit( string $value, int $length ): string { + return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $length ) : substr( $value, 0, $length ); + } +} diff --git a/includes/class-spacefast-sync-state.php b/includes/class-spacefast-sync-state.php index 2042045..1c0959e 100644 --- a/includes/class-spacefast-sync-state.php +++ b/includes/class-spacefast-sync-state.php @@ -7,7 +7,7 @@ final class Spacefast_Sync_State { const HOOK = 'spacefast_wordpress_deliver'; /** - * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,last_build_id:string,last_version_id:string,last_change_at:int} + * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool} */ public static function defaults(): array { return array( @@ -24,11 +24,14 @@ public static function defaults(): array { 'last_change_at' => 0, 'last_attempt_at' => 0, 'last_success_at' => 0, + 'last_settings_sync_at' => 0, + 'active_generation' => 0, + 'settings_pending' => false, ); } /** - * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,last_build_id:string,last_version_id:string,last_change_at:int} + * @return array{desired:int,delivered:int,event_id:string,reasons:array,attempts:int,next_at:int,last_status:string,last_message:string,last_build_id:string,last_version_id:string,last_change_at:int,last_attempt_at:int,last_success_at:int,last_settings_sync_at:int,active_generation:int,settings_pending:bool} */ public static function get(): array { $value = get_option( self::OPTION, array() ); @@ -56,7 +59,10 @@ public static function record_change( array $state, string $reason, string $even $state['attempts'] = 0; $state['next_at'] = 0; $state['last_change_at'] = time(); - $state['last_status'] = 'pending'; + $active_status = (string) $state['last_status']; + $state['last_status'] = in_array( $active_status, array( 'building', 'exporting', 'uploading', 'finalizing' ), true ) + ? $active_status + : 'pending'; $state['last_message'] = ''; return $state; } @@ -72,10 +78,35 @@ public static function acknowledge( array $state, int $generation, string $build $state['delivered'] = max( (int) $state['delivered'], $generation ); $state['attempts'] = 0; $state['next_at'] = 0; - $state['last_status'] = (int) $state['desired'] > $generation ? 'pending' : 'delivered'; + $state['last_status'] = 'building'; $state['last_message'] = ''; $state['last_build_id'] = $build_id; - $state['last_success_at'] = time(); + return $state; + } + + /** @param array $state State. @return array */ + public static function acknowledge_build_terminal( array $state, string $status, string $message = '' ): array { + $state = array_merge( self::defaults(), $state ); + if ( 'succeeded' === $status ) { + $state['last_status'] = (int) $state['desired'] > (int) $state['delivered'] ? 'pending' : 'live'; + $state['last_message'] = ''; + $state['last_success_at'] = time(); + $state['last_build_id'] = ''; + return $state; + } + $state['last_status'] = 'blocked'; + $state['last_build_id'] = ''; + $state['last_message'] = '' !== $message + ? $message + : 'The build did not finish successfully. The previous live version is safe.'; + return $state; + } + + /** @param array $state State. @return array */ + public static function acknowledge_settings_sync( array $state ): array { + $state = array_merge( self::defaults(), $state ); + $state['last_settings_sync_at'] = time(); + $state['settings_pending'] = false; return $state; } @@ -85,13 +116,17 @@ public static function acknowledge( array $state, int $generation, string $build */ public static function acknowledge_static( array $state, string $version_id, string $status ): array { $state = array_merge( self::defaults(), $state ); - $state['delivered'] = (int) $state['desired']; + $generation = 0 < (int) $state['active_generation'] + ? (int) $state['active_generation'] + : (int) $state['desired']; + $state['delivered'] = max( (int) $state['delivered'], $generation ); $state['attempts'] = 0; $state['next_at'] = 0; - $state['last_status'] = $status; + $state['last_status'] = (int) $state['desired'] > (int) $state['delivered'] ? 'pending' : $status; $state['last_message'] = ''; $state['last_version_id'] = $version_id; $state['last_success_at'] = time(); + $state['active_generation'] = 0; return $state; } diff --git a/readme.txt b/readme.txt index ddbe10d..fb77ee3 100644 --- a/readme.txt +++ b/readme.txt @@ -3,7 +3,7 @@ Contributors: spacefast Tags: static site, headless cms, simply static, deployment Requires at least: 6.5 Requires PHP: 8.1 -Stable tag: 0.4.0 +Stable tag: 0.5.0 License: GPLv2 or later Publish a Simply Static export to Spacefast, or rebuild a headless site when WordPress content changes. @@ -20,17 +20,26 @@ Both modes include a manual action in Settings > Spacefast. Headless repository == Installation == 1. Download and activate this plugin. -2. Open Settings > Spacefast and choose Static WordPress or Headless CMS. -3. Continue to Spacefast, sign in, and authorize one Team. -4. Back in WordPress, choose the Space to publish or rebuild. -5. For Static WordPress, use the provided link to install or activate Simply Static. +2. Open Settings > Spacefast and select Publish with Spacefast. +3. Install Simply Static from the same action when prompted, then authorize one Team in Spacefast. +4. Back in WordPress, create a Space with the suggested name or choose an existing one. +5. Wait for the first version to publish. The plugin shows the live URL when it is ready. == Security == -OAuth access is limited to the Team you authorize and the mode you choose. WordPress then verifies the selected Space before showing Connected. Tokens are stored in non-autoloaded options and are never displayed. +OAuth access is limited to the Team you authorize and the mode you choose. WordPress syncs only the selected Space and waits for a real first publish or build before showing the connection as complete. Tokens are stored in non-autoloaded options and are never displayed. == Changelog == += 0.5.0 = +* Replace the mode-first wizard with a WordPress-first Publish with Spacefast journey. +* Install and activate Simply Static from the primary setup action when permitted. +* Create or choose a Space in WordPress, then verify the first live publish or build before showing Connected. +* Automatically publish static-site changes as well as headless content changes with one debounced delivery lane. +* Keep at most one headless build active and coalesce edits into one successor. +* Sync WordPress title, description, search visibility, and headless source settings without overwriting unrelated Space configuration. +* Put diagnostics and connection management behind secondary disclosures. + = 0.4.0 = * Create and connect a new Space directly from the Static WordPress setup flow. * Request Space management permission so the authorized Team can receive the new Space. diff --git a/spacefast-wordpress.php b/spacefast-wordpress.php index f9eb2b9..84cb17d 100644 --- a/spacefast-wordpress.php +++ b/spacefast-wordpress.php @@ -3,7 +3,7 @@ * Plugin Name: Spacefast * Plugin URI: https://spacefast.com/ * Description: Publishes static WordPress exports or rebuilds a headless Spacefast site. - * Version: 0.4.0 + * Version: 0.5.0 * Update URI: https://github.com/spacefast/wordpress * Requires at least: 6.5 * Requires PHP: 8.1 @@ -14,10 +14,11 @@ defined( 'ABSPATH' ) || exit; -define( 'SPACEFAST_WORDPRESS_VERSION', '0.4.0' ); +define( 'SPACEFAST_WORDPRESS_VERSION', '0.5.0' ); define( 'SPACEFAST_WORDPRESS_FILE', __FILE__ ); require_once __DIR__ . '/includes/class-spacefast-settings.php'; +require_once __DIR__ . '/includes/class-spacefast-site-settings.php'; require_once __DIR__ . '/includes/class-spacefast-sync-state.php'; require_once __DIR__ . '/includes/class-spacefast-oauth.php'; require_once __DIR__ . '/includes/class-spacefast-client.php'; diff --git a/tests/acceptance/wordpress.php b/tests/acceptance/wordpress.php index acd083c..2fada9f 100644 --- a/tests/acceptance/wordpress.php +++ b/tests/acceptance/wordpress.php @@ -25,6 +25,14 @@ function spacefast_accept( bool $condition, string $message ): void { 10 === has_action( 'admin_post_spacefast_wordpress_create_space', array( 'Spacefast_Plugin', 'create_space' ) ), 'Space creation handler is not registered.' ); +spacefast_accept( + 10 === has_action( 'admin_post_spacefast_wordpress_prepare_static', array( 'Spacefast_Plugin', 'prepare_static' ) ), + 'Simply Static setup handler is not registered.' +); +spacefast_accept( + 10 === has_action( 'admin_post_spacefast_wordpress_save_settings', array( 'Spacefast_Plugin', 'save_settings' ) ), + 'WordPress settings sync handler is not registered.' +); spacefast_accept( 10 === has_action( 'admin_post_spacefast_wordpress_change_space', array( 'Spacefast_Plugin', 'change_space' ) ), 'Space change handler is not registered.' @@ -34,10 +42,19 @@ function spacefast_accept( bool $condition, string $message ): void { 'Static mode requested unexpected OAuth scopes.' ); spacefast_accept( - array( 'teams:read', 'spaces:read', 'builds:trigger', 'offline_access' ) === Spacefast_OAuth::scopes( Spacefast_Settings::MODE_HEADLESS ), + array( 'teams:read', 'spaces:read', 'spaces:write', 'builds:trigger', 'offline_access' ) === Spacefast_OAuth::scopes( Spacefast_Settings::MODE_HEADLESS ), 'Headless mode requested unexpected OAuth scopes.' ); +wp_set_current_user( 1 ); +ob_start(); +Spacefast_Plugin::render_admin(); +$first_use_markup = (string) ob_get_clean(); +spacefast_accept( str_contains( $first_use_markup, 'Publish with Spacefast' ), 'First use does not lead with publishing.' ); +spacefast_accept( str_contains( $first_use_markup, 'Install Simply Static and continue' ), 'First use does not offer exporter setup.' ); +spacefast_accept( str_contains( $first_use_markup, ' 'acceptance-client', @@ -45,7 +62,11 @@ function spacefast_accept( bool $condition, string $message ): void { 'refresh_token' => 'acceptance-refresh', 'expires_at' => time() + 900, 'team_id' => 'team_acceptance', + 'team_name' => 'Acceptance Team', 'space_id' => 'spc_acceptance', + 'space_name' => 'Acceptance Space', + 'live_url' => 'https://acceptance.example.test', + 'scope' => 'teams:read spaces:read spaces:write builds:trigger offline_access', 'verified_at' => time(), ) ); @@ -74,8 +95,25 @@ function spacefast_accept( bool $condition, string $message ): void { 'Public content did not receive the 60-second quiet window.' ); +ob_start(); +Spacefast_Plugin::render_admin(); +$connected_markup = (string) ob_get_clean(); +spacefast_accept( str_contains( $connected_markup, 'WordPress sync' ), 'Connected UI does not expose WordPress-owned sync settings.' ); +spacefast_accept( str_contains( $connected_markup, 'Open live site' ), 'Connected UI does not expose the live result.' ); +spacefast_accept( str_contains( $connected_markup, 'Technical details' ), 'Connected UI cannot disclose its receipt on demand.' ); + +Spacefast_Settings::merge( array( 'mode' => Spacefast_Settings::MODE_STATIC ) ); +$change_recorded = new ReflectionProperty( Spacefast_Plugin::class, 'change_recorded' ); +$change_recorded->setValue( null, false ); +wp_clear_scheduled_hook( Spacefast_Sync_State::HOOK ); +wp_update_post( array( 'ID' => $post_id, 'post_title' => 'Static debounce acceptance' ) ); +spacefast_accept( + false !== wp_next_scheduled( Spacefast_Sync_State::HOOK ), + 'Updating public content did not schedule a static publish.' +); + $plugin = get_plugin_data( WP_PLUGIN_DIR . '/spacefast-wordpress/spacefast-wordpress.php', false, false ); -spacefast_accept( '0.4.0' === $plugin['Version'], 'Unexpected plugin version.' ); +spacefast_accept( '0.5.0' === $plugin['Version'], 'Unexpected plugin version.' ); spacefast_accept( 'https://github.com/spacefast/wordpress' === $plugin['UpdateURI'], 'Update URI is missing.' ); Spacefast_Settings::disconnect(); diff --git a/tests/behavior.php b/tests/behavior.php index 0d91e6a..023bacf 100644 --- a/tests/behavior.php +++ b/tests/behavior.php @@ -4,6 +4,7 @@ define( 'ABSPATH', __DIR__ . '/' ); define( 'DAY_IN_SECONDS', 86400 ); +define( 'HOUR_IN_SECONDS', 3600 ); define( 'MINUTE_IN_SECONDS', 60 ); define( 'SPACEFAST_WORDPRESS_VERSION', '0.1.0-test' ); @@ -11,6 +12,8 @@ $GLOBALS['spacefast_scheduled'] = array(); $GLOBALS['spacefast_before_query'] = null; $GLOBALS['spacefast_post_types'] = array(); +$GLOBALS['spacefast_hooks'] = array(); +$GLOBALS['spacefast_remote_handler'] = null; final class WP_Post { public string $post_type; @@ -25,6 +28,21 @@ public function __construct( string $post_type, string $post_status ) { function untrailingslashit( string $value ): string { return rtrim( $value, "/\\" ); } +function __( string $value ): string { + return $value; +} +function sanitize_text_field( string $value ): string { + return trim( strip_tags( $value ) ); +} +function get_bloginfo( string $field ): string { + return 'description' === $field ? 'Demo description' : 'Demo WordPress'; +} +function add_action( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): void { + $GLOBALS['spacefast_hooks'][ $hook ] = array( $callback, $priority, $accepted_args ); +} +function add_filter( string $hook, $callback, int $priority = 10, int $accepted_args = 1 ): void { + $GLOBALS['spacefast_hooks'][ $hook ] = array( $callback, $priority, $accepted_args ); +} function wp_parse_url( string $value ) { return parse_url( $value ); } @@ -105,6 +123,10 @@ function wp_remote_retrieve_response_code( array $response ): int { function wp_remote_retrieve_body( array $response ): string { return (string) $response['body']; } +function wp_safe_remote_request( string $url, array $args ) { + $handler = $GLOBALS['spacefast_remote_handler']; + return is_callable( $handler ) ? $handler( $url, $args ) : new Spacefast_Test_Wp_Error(); +} final class Spacefast_Test_Wp_Error {} final class Spacefast_Test_Wpdb { @@ -143,6 +165,7 @@ public function query( array $prepared ): int { require_once dirname( __DIR__ ) . '/includes/class-spacefast-sync-state.php'; require_once dirname( __DIR__ ) . '/includes/class-spacefast-settings.php'; +require_once dirname( __DIR__ ) . '/includes/class-spacefast-site-settings.php'; require_once dirname( __DIR__ ) . '/includes/class-spacefast-oauth.php'; require_once dirname( __DIR__ ) . '/includes/class-spacefast-client.php'; require_once dirname( __DIR__ ) . '/includes/class-spacefast-static-publisher.php'; @@ -183,13 +206,16 @@ function raw_response( int $status, array $data = array() ): array { ); } +Spacefast_Plugin::boot(); +check( isset( $GLOBALS['spacefast_hooks']['update_option_home'] ), 'changing the WordPress Site Address queues settings sync' ); + check( array( 'teams:read', 'spaces:read', 'spaces:write', 'spaces:publish', 'offline_access' ) === Spacefast_OAuth::scopes( Spacefast_Settings::MODE_STATIC ), 'static OAuth asks for Team discovery, Space creation and discovery, publishing, and refresh access' ); check( - array( 'teams:read', 'spaces:read', 'builds:trigger', 'offline_access' ) === Spacefast_OAuth::scopes( Spacefast_Settings::MODE_HEADLESS ), - 'headless OAuth asks only for Team discovery, Space discovery, builds, and refresh access' + array( 'teams:read', 'spaces:read', 'spaces:write', 'builds:trigger', 'offline_access' ) === Spacefast_OAuth::scopes( Spacefast_Settings::MODE_HEADLESS ), + 'headless OAuth can sync the WordPress source before triggering builds' ); Spacefast_Settings::merge( array( 'scope' => 'teams:read spaces:read spaces:publish offline_access' ) ); check( ! Spacefast_OAuth::has_scope( 'spaces:write' ), 'upgraded static authorization does not imply Space management access' ); @@ -213,7 +239,7 @@ function raw_response( int $status, array $data = array() ): array { 'access_token' => 'oauth_access', 'refresh_token' => 'oauth_refresh', 'expires_in' => 900, - 'scope' => 'teams:read spaces:read builds:trigger offline_access', + 'scope' => 'teams:read spaces:read spaces:write builds:trigger offline_access', ) ); } @@ -350,7 +376,7 @@ static function ( string $url, array $args ) use ( &$revoke_requests ): array { 'access_token' => 'access_secret', 'refresh_token' => 'refresh_secret', 'expires_at' => time() + 3600, - 'scope' => 'teams:read spaces:read builds:trigger offline_access', + 'scope' => 'teams:read spaces:read spaces:write builds:trigger offline_access', 'team_id' => 'team_demo', 'team_name' => 'Demo Team', 'team_slug' => 'demo-team', @@ -360,6 +386,66 @@ static function ( string $url, array $args ) use ( &$revoke_requests ): array { 'live_url' => 'https://demo.spacefast.site', 'verified_at' => time(), ); +Spacefast_Settings::merge( $connection ); +$resume_authorization = $oauth->begin( Spacefast_Settings::MODE_HEADLESS ); +check( true === $resume_authorization['ok'], 'connected installations can reauthorize in place' ); +$resume_pending = get_option( Spacefast_OAuth::PENDING_OPTION ); +$resumed = $oauth->finish( 'resume-code', (string) $resume_pending['state'] ); +check( true === ( $resumed['resumed'] ?? false ), 'reauthorization resumes the existing accessible Space' ); +check( 'spc_demo' === Spacefast_Settings::get()['space_id'], 'reauthorization does not force another Space selection' ); + +Spacefast_Settings::merge( + array_merge( + $connection, + array( 'scope' => 'teams:read spaces:read builds:trigger offline_access' ) + ) +); +Spacefast_Sync_State::save( + array_merge( + Spacefast_Sync_State::defaults(), + array( + 'desired' => 8, + 'delivered' => 8, + 'last_status' => 'building', + 'last_build_id' => 'bld_previous_space', + ) + ) +); +$connection_requests = array(); +$GLOBALS['spacefast_remote_handler'] = static function ( string $url, array $args ) use ( &$connection_requests ): array { + $connection_requests[] = array( $url, $args ); + if ( 'POST' === $args['method'] && str_ends_with( $url, '/builds/wordpress' ) ) { + return response( 201, array( 'build' => array( 'id' => 'bld_new_space', 'status' => 'queued' ) ) ); + } + return response( 200, array( 'ready' => true ) ); +}; +$change_recorded = new ReflectionProperty( Spacefast_Plugin::class, 'change_recorded' ); +$change_recorded->setValue( null, false ); +$connect_space = new ReflectionMethod( Spacefast_Plugin::class, 'connect_space' ); +$connected_new_space = $connect_space->invoke( + null, + array( + 'id' => 'spc_new', + 'teamId' => 'team_demo', + 'title' => 'New Space', + 'slug' => 'new-space', + 'liveUrl' => 'https://new.spacefast.site', + ), + array( 'id' => 'team_demo', 'name' => 'Demo Team', 'slug' => 'demo-team' ) +); +check( true === $connected_new_space['ok'], 'an upgraded token can select a Space before settings reauthorization' ); +$new_space_state = Spacefast_Sync_State::get(); +check( 'bld_new_space' === $new_space_state['last_build_id'], 'a new Space starts its own first build instead of polling the previous Space' ); +check( 1 === $new_space_state['desired'], 'switching Spaces resets the previous delivery generation' ); +check( + ! str_contains( implode( ' ', array_column( $connection_requests, 0 ) ), 'bld_previous_space' ), + 'switching Spaces discards the previous active build receipt' +); +check( true === $new_space_state['settings_pending'], 'settings remain queued until the upgraded token is reauthorized' ); + +Spacefast_Settings::merge( $connection ); +$GLOBALS['spacefast_remote_handler'] = null; +Spacefast_Sync_State::save( Spacefast_Sync_State::defaults() ); $state = Spacefast_Sync_State::record_change( Spacefast_Sync_State::defaults(), @@ -374,7 +460,10 @@ static function ( string $url, array $args ) use ( &$revoke_requests ): array { $state['desired'] = 3; $ack = Spacefast_Sync_State::acknowledge( $state, 2, 'bld_test' ); check( 2 === $ack['delivered'], 'acknowledges only observed generation' ); -check( 'pending' === $ack['last_status'], 'preserves an edit arriving during delivery' ); +check( 'building' === $ack['last_status'], 'keeps the active build visible until it reaches a terminal state' ); +$terminal = Spacefast_Sync_State::acknowledge_build_terminal( $ack, 'succeeded' ); +check( 'pending' === $terminal['last_status'], 'queues one successor when an edit arrived during the build' ); +check( '' === $terminal['last_build_id'], 'terminal builds clear their active polling receipt' ); check( 60 === Spacefast_Sync_State::retry_delay( 1 ), 'starts bounded backoff' ); check( DAY_IN_SECONDS === Spacefast_Sync_State::retry_delay( 99 ), 'caps backoff' ); @@ -403,7 +492,90 @@ static function ( string $url, array $args ) use ( &$revoke_requests ): array { check( 2 === $cas_ack['desired'], 'CAS preserves a concurrent content generation' ); check( 1 === $cas_ack['delivered'], 'CAS acknowledges only the delivered generation' ); check( 'event-two' === $cas_ack['event_id'], 'CAS preserves the successor event id' ); -check( 'pending' === $cas_ack['last_status'], 'CAS leaves the successor pending' ); +check( 'building' === $cas_ack['last_status'], 'CAS keeps the accepted build active while preserving its successor' ); + +$invalid_response = new Spacefast_Client( + static fn(): array => array( + 'response' => array( 'code' => 200 ), + 'body' => '{"data":{"config":', + ) +); +$invalid_space = $invalid_response->get_space(); +check( ! $invalid_space['ok'], 'a truncated successful Space response is never patched as empty config' ); +check( 'invalid_response_body' === $invalid_space['code'], 'truncated JSON has a stable retryable error' ); + +$poll_build = new ReflectionMethod( Spacefast_Plugin::class, 'poll_headless_build' ); +$GLOBALS['spacefast_scheduled'] = array(); +$failed_build_state = array_merge( + Spacefast_Sync_State::defaults(), + array( + 'desired' => 2, + 'delivered' => 1, + 'event_id' => 'event-successor', + 'last_status' => 'building', + 'last_build_id' => 'bld_failed', + 'last_change_at' => time() - 120, + ) +); +Spacefast_Sync_State::save( $failed_build_state ); +$GLOBALS['spacefast_remote_handler'] = static fn(): array => response( + 200, + array( 'id' => 'bld_failed', 'status' => 'failed', 'diagnostics' => array() ) +); +$poll_build->invoke( null, $failed_build_state ); +check( 'blocked' === Spacefast_Sync_State::get()['last_status'], 'a failed build keeps its safe failure state visible' ); +check( false !== wp_next_scheduled( Spacefast_Sync_State::HOOK ), 'a content change queued during a failed build schedules one successor' ); + +$GLOBALS['spacefast_scheduled'] = array(); +$unknown_build_state = array_merge( + Spacefast_Sync_State::defaults(), + array( + 'desired' => 1, + 'delivered' => 1, + 'last_status' => 'building', + 'last_build_id' => 'bld_unknown', + ) +); +Spacefast_Sync_State::save( $unknown_build_state ); +$GLOBALS['spacefast_remote_handler'] = static fn(): array => response( + 200, + array( 'id' => 'bld_unknown', 'status' => 'observing' ) +); +$poll_build->invoke( null, $unknown_build_state ); +check( 'building' === Spacefast_Sync_State::get()['last_status'], 'an unknown build status does not become a false failure' ); +check( false !== wp_next_scheduled( Spacefast_Sync_State::HOOK ), 'an unknown build status is polled again' ); +$GLOBALS['spacefast_remote_handler'] = null; + +$mapping = Spacefast_Site_Settings::patch( + array( + 'title' => 'Old title', + 'noindex' => false, + 'settingsDigest' => str_repeat( 'a', 64 ), + 'config' => array( + 'cleanUrls' => true, + 'dataSources' => array( + 'catalog' => array( 'kind' => 'wordpress', 'url' => 'https://catalog.example.test' ), + ), + ), + ), + array( 'sync_title' => true, 'sync_visibility' => true, 'sync_source' => true ), + array( + 'title' => 'New WordPress title', + 'description' => 'A synced description.', + 'noindex' => true, + 'source_url' => 'https://wp.example.test', + ), + Spacefast_Settings::MODE_HEADLESS +); +check( true === $mapping['config']['cleanUrls'], 'settings sync preserves unrelated Space config' ); +check( isset( $mapping['config']['dataSources']['catalog'] ), 'settings sync preserves unrelated data sources' ); +check( + array( 'kind' => 'wordpress', 'url' => 'https://wp.example.test' ) === $mapping['config']['dataSources']['wordpress'], + 'settings sync configures this WordPress site as a source' +); +check( 'wordpress' === $mapping['config']['defaultDataSource'], 'settings sync makes the connected WordPress source the build default' ); +check( true === $mapping['noindex'], 'settings sync mirrors WordPress search visibility' ); +check( str_repeat( 'a', 64 ) === $mapping['baseSettingsDigest'], 'settings sync uses the current compare-and-swap base' ); Spacefast_Sync_State::save( Spacefast_Sync_State::defaults() ); check( @@ -458,6 +630,21 @@ static function ( string $url, array $args ) use ( &$revoke_requests ): array { 'reactivation recovers pending work' ); +$GLOBALS['spacefast_scheduled'] = array(); +Spacefast_Sync_State::save( + array_merge( + Spacefast_Sync_State::defaults(), + array( + 'desired' => 1, + 'delivered' => 1, + 'last_status' => 'building', + 'last_build_id' => 'bld_reactivate', + ) + ) +); +Spacefast_Plugin::activate(); +check( false !== wp_next_scheduled( Spacefast_Sync_State::HOOK ), 'reactivation resumes terminal polling for an accepted build' ); + Spacefast_Settings::disconnect(); Spacefast_Settings::merge( $connection ); $reconnected = Spacefast_Sync_State::mutate( @@ -477,6 +664,10 @@ static function ( string $url, array $args ) use ( &$revoke_requests ): array { 'public' => true, 'show_in_rest' => true, ); +$GLOBALS['spacefast_post_types']['legacy_public'] = (object) array( + 'public' => true, + 'show_in_rest' => false, +); $before_delete = Spacefast_Sync_State::get(); Spacefast_Plugin::post_deleted( 1, new WP_Post( 'internal', 'publish' ) ); check( @@ -490,6 +681,20 @@ static function ( string $url, array $args ) use ( &$revoke_requests ): array { $before_delete['desired'] + 1 === Spacefast_Sync_State::get()['desired'], 'rebuilds after deleting a public REST-visible post' ); +$headless_generation = Spacefast_Sync_State::get()['desired']; +$change_recorded->setValue( null, false ); +Spacefast_Plugin::post_deleted( 3, new WP_Post( 'legacy_public', 'publish' ) ); +check( + $headless_generation === Spacefast_Sync_State::get()['desired'], + 'headless mode ignores public content unavailable through the REST API' +); +Spacefast_Settings::merge( array( 'mode' => Spacefast_Settings::MODE_STATIC ) ); +$change_recorded->setValue( null, false ); +Spacefast_Plugin::post_deleted( 4, new WP_Post( 'legacy_public', 'publish' ) ); +check( + $headless_generation + 1 === Spacefast_Sync_State::get()['desired'], + 'static mode republishes public content even when it is not REST-visible' +); Spacefast_Settings::merge( $connection ); $requests = array(); @@ -670,6 +875,10 @@ static function ( string $url, string $method, array $headers, string $file ) us $create_body = json_decode( $static_requests[0][1]['body'], true ); check( 'snapshot' === $create_body['publishMode'], 'publishes the export as an exact snapshot' ); check( array( 'channel' => 'live' ) === $create_body['finalize'], 'requests live auto-finalize up front' ); +check( + 'https://wp.example.test' === $create_body['source']['metadata']['siteUrl'], + 'attributes the published version to its public WordPress site' +); check( str_ends_with( $static_requests[1][3], '/index.html' ), 'uploads only the file named by the opaque server target' @@ -680,16 +889,83 @@ static function ( string $url, string $method, array $headers, string $file ) us ); Spacefast_Sync_State::save( - array_merge( Spacefast_Sync_State::defaults(), array( 'desired' => 1 ) ) + array_merge( Spacefast_Sync_State::defaults(), array( 'desired' => 2, 'active_generation' => 1 ) ) ); $static_ack = Spacefast_Sync_State::acknowledge_static( Spacefast_Sync_State::get(), 'ver_static', 'live' ); -check( 'live' === $static_ack['last_status'], 'records verified live publication separately from builds' ); +check( 'pending' === $static_ack['last_status'], 'a change during export remains queued for one successor publish' ); +check( 1 === $static_ack['delivered'], 'static publishing acknowledges only the generation that was exported' ); check( 'ver_static' === $static_ack['last_version_id'], 'keeps the last static version id' ); +$fallback_static_ack = Spacefast_Sync_State::acknowledge_static( + array_merge( + Spacefast_Sync_State::defaults(), + array( 'desired' => 4, 'delivered' => 3, 'active_generation' => 0 ) + ), + 'ver_without_start_hook', + 'live' +); +check( 4 === $fallback_static_ack['delivered'], 'static completion falls back to the desired generation when the exporter start hook is absent' ); +check( 'live' === $fallback_static_ack['last_status'], 'a missing exporter start hook cannot create a publish loop' ); + +eval( + 'namespace Simply_Static { + class Task {} + class Options { + private static $instance; + public static function instance() { return self::$instance ??= new self(); } + public function set($key, $value) { return $this; } + public function save() { return true; } + } + class Plugin { + public static $runs = 0; + private static $instance; + public static function instance() { return self::$instance ??= new self(); } + public function run_static_export() { self::$runs++; return true; } + } + }' +); +$static_connection = array_merge( + $connection, + array( + 'mode' => Spacefast_Settings::MODE_STATIC, + 'scope' => 'teams:read spaces:read spaces:write spaces:publish offline_access', + ) +); +Spacefast_Settings::merge( $static_connection ); +$deliver_static = new ReflectionMethod( Spacefast_Plugin::class, 'deliver_static' ); +$stale_export = array_merge( + Spacefast_Sync_State::defaults(), + array( + 'desired' => 1, + 'last_status' => 'exporting', + 'last_attempt_at' => time() - Spacefast_Plugin::STATIC_DELIVERY_STALE_SECONDS - 1, + ) +); +Spacefast_Sync_State::save( $stale_export ); +$deliver_static->invoke( null, $stale_export ); +check( 1 === \Simply_Static\Plugin::$runs, 'an abandoned static export is restarted after the bounded stale window' ); +check( 'exporting' === Spacefast_Sync_State::get()['last_status'], 'a recovered static export returns to an active state' ); +check( time() - 2 <= Spacefast_Sync_State::get()['last_attempt_at'], 'a recovered static export records a fresh heartbeat' ); + +\Simply_Static\Plugin::$runs = 0; +$GLOBALS['spacefast_scheduled'] = array(); +$recent_export = array_merge( + $stale_export, + array( 'last_attempt_at' => time() ) +); +Spacefast_Sync_State::save( $recent_export ); +$deliver_static->invoke( null, $recent_export ); +check( 0 === \Simply_Static\Plugin::$runs, 'a recent active export is not started twice' ); +check( false !== wp_next_scheduled( Spacefast_Sync_State::HOOK ), 'an active export retains a stale-recovery watchdog' ); +Spacefast_Plugin::static_publish_progress( 'uploading' ); +check( 'uploading' === Spacefast_Sync_State::get()['last_status'], 'static upload progress refreshes the delivery heartbeat' ); + +Spacefast_Settings::merge( $connection ); + unlink( $archive . '/assets/app.js' ); rmdir( $archive . '/assets' ); unlink( $archive . '/index.html' );