Hooks added while starting. */ private static array $added = array(); /** * @param callable(): void $start * @throws \Throwable Only when $rethrow is on (tests). */ public static function boot( callable $start ): void { self::$booting = true; try { $start(); } catch ( \Throwable $e ) { if ( self::$rethrow ) { throw $e; } self::stop( $e ); } finally { self::$booting = false; self::$added = array(); } } public static function stopped(): bool { return null !== self::$boot_error; } /** For tests. */ public static function reset(): void { self::$boot_error = null; self::$added = array(); } /** Remember a hook added while starting, so a failed start can take it back. */ public static function track( string $hook, callable $callback, int $priority ): void { if ( self::$booting ) { self::$added[] = array( $hook, $callback, $priority ); } } /** * The callback wrapped in try/catch. * * @param bool $fallback_arg true: return the first argument on error (filters). */ public static function guard( callable $callback, string $hook, bool $fallback_arg ): \Closure { return static fn( ...$args ) => self::run( $callback, $args, $hook, $fallback_arg ); } /** * @param array $args * @throws \Throwable Only when $rethrow is on (tests). */ private static function run( callable $callback, array $args, string $hook, bool $fallback_arg ): mixed { try { return $callback( ...$args ); } catch ( \Throwable $e ) { if ( self::$rethrow ) { throw $e; } self::report( $e, $hook ); return $fallback_arg ? ( $args[0] ?? null ) : null; } } /** * Run a public API call: the fallback when the plugin is off or the call fails. * * @template T * @param callable(): T $callback * @param T $fallback * @return T * @throws \Throwable Only when $rethrow is on (tests). */ public static function call( callable $callback, mixed $fallback, string $name ): mixed { if ( self::stopped() ) { return $fallback; } try { return $callback(); } catch ( \Throwable $e ) { if ( self::$rethrow ) { throw $e; } self::report( $e, $name . '()' ); return $fallback; } } /** PHP log, and a transient the admin notice reads (written once per distinct error). */ public static function report( \Throwable $e, string $hook ): void { $details = self::details( $e ); error_log( 'Blocklingo: error in ' . $hook . ': ' . $details ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- the site owner needs it in the PHP log. $last = get_transient( self::LAST_ERROR ); if ( ! is_array( $last ) || ( $last['details'] ?? '' ) !== $details ) { set_transient( self::LAST_ERROR, array( 'hook' => $hook, 'details' => $details, 'time' => time(), ), WEEK_IN_SECONDS ); } } private static function stop( \Throwable $e ): void { self::$boot_error = $e; foreach ( self::$added as $added ) { remove_filter( $added[0], $added[1], $added[2] ); } error_log( 'Blocklingo could not start and is off for this request: ' . self::details( $e ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- the site owner needs it in the PHP log. add_action( 'admin_notices', array( self::class, 'boot_notice' ) ); add_action( 'network_admin_notices', array( self::class, 'boot_notice' ) ); } public static function boot_notice(): void { if ( ! self::$boot_error || ! current_user_can( 'activate_plugins' ) ) { return; } $text = __( 'Blocklingo stopped because of an error, so it is not running right now. Your site keeps working without it, but language URLs and translations are off until this is fixed. Reinstalling the plugin usually helps; if not, send these details to the developer:', 'blocklingo' ); self::print_notice( $text, self::details( self::$boot_error ) ); } /** Last error caught in a hook: on the Plugins screen and the Blocklingo pages. */ public static function runtime_notice(): void { $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; $last = get_transient( self::LAST_ERROR ); if ( ! is_array( $last ) || ! current_user_can( 'manage_options' ) || ! $screen || ( 'plugins' !== $screen->id && ! str_contains( (string) $screen->id, 'blocklingo' ) && ! str_contains( (string) $screen->id, 'blocklingo-strings' ) ) ) { return; } $text = sprintf( /* translators: %s: date and time of the error. */ __( 'Blocklingo hit an error on %s and skipped that step, so the page still loaded. If something looks wrong, send these details to the developer:', 'blocklingo' ), wp_date( (string) get_option( 'date_format' ) . ' ' . (string) get_option( 'time_format' ), (int) ( $last['time'] ?? time() ) ) ); self::print_notice( $text, (string) ( $last['hook'] ?? '' ) . ': ' . (string) ( $last['details'] ?? '' ) ); } private static function print_notice( string $text, string $details ): void { printf( '

%s

%s

%s

', esc_html( $text ), esc_html( $details ), esc_url( (string) apply_filters( 'blocklingo_support_url', 'https://alexdenysiuk.com/' ) ), esc_html__( 'Contact the developer', 'blocklingo' ) ); } private static function details( \Throwable $e ): string { $file = str_replace( wp_normalize_path( WP_CONTENT_DIR ), 'wp-content', wp_normalize_path( $e->getFile() ) ); return sprintf( '%s: %s in %s:%d', get_class( $e ), $e->getMessage(), $file, $e->getLine() ); } }