'object', 'single' => true, 'show_in_rest' => false, 'sanitize_callback' => static function ( $value ) { $normalized = self::normalize_page_css( $value ); return $normalized['valid'] && is_array( $normalized['value'] ) ? $normalized['value'] : array(); }, 'auth_callback' => static function () { return current_user_can( 'manage_options' ); }, ) ); } /** * Store or remove page CSS for a post. * * @param int $post_id Post ID. * @param mixed $value Requested page CSS payload. */ public static function update_post_page_css( int $post_id, $value ): array { $normalized = self::normalize_page_css( $value ); if ( ! $normalized['valid'] ) { return $normalized; } if ( null === $normalized['value'] ) { delete_post_meta( $post_id, self::META_PAGE_CSS ); return array( 'valid' => true, 'value' => null, ); } update_post_meta( $post_id, self::META_PAGE_CSS, $normalized['value'] ); return $normalized; } /** * Validate and normalize a page CSS payload. * * @param mixed $value Candidate payload. */ public static function normalize_page_css( $value ): array { if ( null === $value || false === $value || array() === $value || '' === $value ) { return array( 'valid' => true, 'value' => null, ); } if ( ! is_array( $value ) ) { return self::invalid( 'page_css_must_be_object' ); } $scope_class = isset( $value['scope_class'] ) && is_scalar( $value['scope_class'] ) ? self::normalize_scope_class( (string) $value['scope_class'] ) : ''; if ( '' === $scope_class ) { return self::invalid( 'invalid_scope_class' ); } if ( ! isset( $value['css'] ) || ! is_string( $value['css'] ) ) { return self::invalid( 'css_must_be_string' ); } $css = str_replace( "\0", '', wp_unslash( $value['css'] ) ); if ( '' === trim( $css ) ) { return array( 'valid' => true, 'value' => null, ); } if ( strlen( $css ) > self::MAX_CSS_BYTES ) { return self::invalid( 'css_too_large' ); } if ( preg_match( '/<|<\/?style\b|script\b|javascript\s*:|@import\b|expression\s*\(|behavior\s*:|-moz-binding\s*:/i', $css ) ) { return self::invalid( 'css_contains_disallowed_construct' ); } if ( ! preg_match( '/\.' . preg_quote( $scope_class, '/' ) . '(?![-_a-zA-Z0-9])/', $css ) ) { return self::invalid( 'css_missing_scope_class' ); } return array( 'valid' => true, 'value' => array( 'version' => self::VERSION, 'enabled' => true, 'scope_class' => $scope_class, 'css' => $css, ), ); } /** * Return a compact summary of stored page CSS. * * @param int $post_id Post ID. */ public static function get_page_css_summary( int $post_id ): array { $page_css = self::get_page_css_meta( $post_id ); if ( ! $page_css ) { return array( 'enabled' => false, ); } return array( 'enabled' => true, 'scope_class' => $page_css['scope_class'], 'css_bytes' => strlen( $page_css['css'] ), 'css_hash' => 'sha256:' . hash( 'sha256', $page_css['css'] ), ); } /** * Load normalized page CSS meta. * * @param int $post_id Post ID. */ public static function get_page_css_meta( int $post_id ): ?array { $value = get_post_meta( $post_id, self::META_PAGE_CSS, true ); if ( ! is_array( $value ) || empty( $value['enabled'] ) || empty( $value['scope_class'] ) || ! isset( $value['css'] ) || ! is_string( $value['css'] ) ) { return null; } $scope_class = self::normalize_scope_class( (string) $value['scope_class'] ); if ( '' === $scope_class ) { return null; } return array( 'version' => isset( $value['version'] ) ? absint( $value['version'] ) : self::VERSION, 'enabled' => true, 'scope_class' => $scope_class, 'css' => (string) $value['css'], ); } /** * Enqueue page CSS on singular frontend requests. */ public function enqueue_frontend_styles(): void { if ( ! is_singular() ) { return; } $post_id = get_queried_object_id(); if ( $post_id <= 0 ) { return; } $this->enqueue_inline_css( 'actionpanel-ai-page-css', self::get_css_for_post( (int) $post_id ) ); } /** * Load page CSS in non-iframed editor contexts. */ public function enqueue_editor_assets(): void { $post_id = $this->get_current_editor_post_id(); if ( $post_id <= 0 ) { return; } $this->enqueue_inline_css( 'actionpanel-ai-page-css-editor', self::get_css_for_post( $post_id ) ); } /** * Load page CSS into the iframed block editor canvas. * * @param array $settings Block editor settings. * @param mixed $context Block editor context. */ public function add_editor_styles( array $settings, $context ): array { $post = isset( $context->post ) && $context->post instanceof \WP_Post ? $context->post : null; if ( ! $post ) { return $settings; } $css = self::get_css_for_post( (int) $post->ID ); if ( '' === $css ) { return $settings; } $settings['styles'] = isset( $settings['styles'] ) && is_array( $settings['styles'] ) ? $settings['styles'] : array(); $settings['styles'][] = array( 'css' => $css, '__unstableType' => 'theme', ); return $settings; } /** * Return the CSS string for a post. * * @param int $post_id Post ID. */ private static function get_css_for_post( int $post_id ): string { $page_css = self::get_page_css_meta( $post_id ); return $page_css ? (string) $page_css['css'] : ''; } /** * Enqueue inline CSS under a synthetic style handle. * * @param string $handle Style handle. * @param string $css CSS text. */ private function enqueue_inline_css( string $handle, string $css ): void { if ( '' === $css ) { return; } wp_register_style( $handle, false, array(), defined( 'ACTIONPANEL_AI_PLUGIN_VERSION' ) ? ACTIONPANEL_AI_PLUGIN_VERSION : null ); wp_enqueue_style( $handle ); wp_add_inline_style( $handle, $css ); } /** * Resolve the post currently loaded in the block editor. */ private function get_current_editor_post_id(): int { global $post; if ( $post instanceof \WP_Post ) { return (int) $post->ID; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only editor routing value. if ( isset( $_GET['post'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- wp_unslash() followed by absint() normalizes a read-only editor post ID. return absint( wp_unslash( $_GET['post'] ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only editor context value. if ( isset( $_POST['post_ID'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- wp_unslash() followed by absint() normalizes a read-only editor post ID. return absint( wp_unslash( $_POST['post_ID'] ) ); } return 0; } /** * Normalize a CSS class token. * * @param string $scope_class Raw class. */ private static function normalize_scope_class( string $scope_class ): string { $scope_class = ltrim( trim( sanitize_html_class( $scope_class ) ), '.' ); return preg_match( '/^[A-Za-z][A-Za-z0-9_-]{0,80}$/', $scope_class ) ? $scope_class : ''; } /** * Build a validation failure. * * @param string $reason Machine-readable reason. */ private static function invalid( string $reason ): array { return array( 'valid' => false, 'reason' => $reason, 'value' => null, ); } }