settings = $settings; } /** * Register routes. */ public function register() { add_action( 'rest_api_init', array( $this, 'routes' ) ); } /** * Define the routes. */ public function routes() { register_rest_route( self::NAMESPACE, '/settings', array( array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_settings' ), 'permission_callback' => array( $this, 'can_manage' ), ), array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'save_settings' ), 'permission_callback' => array( $this, 'can_manage' ), ), ) ); register_rest_route( self::NAMESPACE, '/settings/reset', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'reset_settings' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/onboarding', array( 'methods' => \WP_REST_Server::EDITABLE, 'callback' => array( $this, 'complete_onboarding' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/readiness', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_readiness' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); register_rest_route( self::NAMESPACE, '/score', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_score' ), 'permission_callback' => array( $this, 'can_manage' ), ) ); // JSON-LD preview: the exact @graph the front end would emit for the site // or a chosen post, so an owner can inspect and validate it without viewing // page source. `post` = 0/absent → the site-wide identity graph. register_rest_route( self::NAMESPACE, '/preview/schema', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_schema_preview' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'post' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); // The Markdown twin of a page/post — what an agent receives when it requests // the page as text/markdown. The site target (post = 0) previews the index // Markdown served at the home URL and /llms.txt. register_rest_route( self::NAMESPACE, '/preview/markdown', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_markdown_preview' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'post' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); // The pickable targets for the preview: the in-scope pages & posts, most // recently edited first, optionally filtered by a title search. Grouped by // post type and paginated per group — `type` + `offset` fetch a group's next // batch for the "Load more" button (see get_schema_targets()). register_rest_route( self::NAMESPACE, '/preview/targets', array( 'methods' => \WP_REST_Server::READABLE, 'callback' => array( $this, 'get_schema_targets' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'search' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), 'type' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_key', ), 'offset' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint', ), ), ) ); // POST /optimize/ignore — set aside (or restore) a page from citability grading. register_rest_route( self::NAMESPACE, '/optimize/ignore', array( 'methods' => \WP_REST_Server::CREATABLE, 'callback' => array( $this, 'optimize_ignore' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'post' => array( 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint', ), 'ignored' => array( 'type' => 'boolean', 'default' => true, 'sanitize_callback' => 'rest_sanitize_boolean', ), ), ) ); } /** * Permission gate. * * @return bool */ public function can_manage() { return current_user_can( 'manage_options' ); } /** * GET /settings. * * @return \WP_REST_Response */ public function get_settings() { return rest_ensure_response( array( 'settings' => $this->settings->all(), 'readiness' => ( new Readiness( $this->settings ) )->report(), // The freshly-expanded exposed-files probe list, so the admin's // browser-side scan reflects a just-edited custom path without a reload. 'exposedPaths' => Exposure::sensitive_paths( $this->settings ), ) ); } /** * POST /settings. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function save_settings( \WP_REST_Request $request ) { $input = $request->get_json_params(); if ( ! is_array( $input ) ) { $input = (array) $request->get_param( 'settings' ); } if ( isset( $input['settings'] ) && is_array( $input['settings'] ) ) { $input = $input['settings']; } $saved = $this->settings->update( (array) $input ); $readiness = ( new Readiness( $this->settings ) )->report(); return rest_ensure_response( array( 'settings' => $saved, 'readiness' => $readiness, // The recomputed score, so the rail card (e.g. the Cited rung when // citation tracking is toggled) updates live without a page reload. 'score' => $this->score_report( $readiness ), 'exposedPaths' => Exposure::sensitive_paths( $this->settings ), 'saved' => true, ) ); } /** * The AEO/GEO score, computed fail-open (a throw degrades to null rather than failing * the settings save). * * @param array> $readiness Precomputed readiness rows. * @return array|null */ private function score_report( array $readiness ) { try { return ( new Score( $this->settings ) )->report( $readiness ); } catch ( \Throwable $e ) { return null; } } /** * POST /optimize/ignore — set aside (ignored=true) or restore (false) a page from * content-citability grading. Returns the recomputed score so the worklist, the * set-aside list, and the counts update without a reload. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function optimize_ignore( \WP_REST_Request $request ) { $id = absint( $request->get_param( 'post' ) ); if ( $id < 1 ) { return new \WP_Error( 'agentimus_bad_post', __( 'Invalid post.', 'agentimus' ), array( 'status' => 400 ) ); } $ignore = (bool) $request->get_param( 'ignored' ); $all = $this->settings->all(); $list = ( isset( $all['optimize_ignored'] ) && is_array( $all['optimize_ignored'] ) ) ? array_map( 'intval', $all['optimize_ignored'] ) : array(); $list = array_values( array_diff( $list, array( $id ) ) ); if ( $ignore ) { $list[] = $id; } $all['optimize_ignored'] = $list; $this->settings->update( $all ); // Sanitises + busts the OPTIMIZE cache. return rest_ensure_response( array( 'score' => ( new Score( new Settings() ) )->report(), 'saved' => true, ) ); } /** * POST /settings/reset — restore factory defaults. * * @return \WP_REST_Response */ public function reset_settings() { $defaults = $this->settings->reset(); return rest_ensure_response( array( 'settings' => $defaults, 'readiness' => ( new Readiness( $this->settings ) )->report(), 'exposedPaths' => Exposure::sensitive_paths( $this->settings ), 'reset' => true, ) ); } /** * POST /onboarding — mark the first-run setup wizard complete (or skipped) so * it never shows again. Stored as its own option, not inside settings, so a * factory reset doesn't re-trigger the wizard. * * @return \WP_REST_Response */ public function complete_onboarding() { update_option( 'agentimus_onboarded', AGENTIMUS_VERSION ); return rest_ensure_response( array( 'onboarded' => true ) ); } /** * GET /readiness. * * @return \WP_REST_Response */ public function get_readiness() { return rest_ensure_response( ( new Readiness( $this->settings ) )->report() ); } /** * GET /score — the composite AEO/GEO score, recomputed. * * The score card used to arrive only two ways: with the page's initial bootstrap, or in * the reply to a settings save. So an edit made anywhere else — publishing a post in * another tab, which is the normal case — left an open dashboard showing a stale score * and a "next step" pointing at a page the owner had already fixed, with no way back but * a full page reload. This route gives the admin a way to ask for the current score. * * @return \WP_REST_Response */ public function get_score() { $readiness = ( new Readiness( $this->settings ) )->report(); return rest_ensure_response( array( 'score' => $this->score_report( $readiness ) ) ); } /** * GET /preview/schema — the JSON-LD document for the site or a chosen post. * * Returns the graph regardless of whether the front end is currently emitting * it (schema disabled, or an SEO plugin owns it): the point of a preview is to * show what WOULD ship. The `active`/`reason` fields let the UI explain when the * live `` is empty. An unpublished post (draft/pending/scheduled/private) * is previewed with its would-be per-post node — flagged by `postNote` as not yet * live — so the owner sees what publishing will emit. A password-protected post * is the one exception: its body never ships as schema, so it still yields only * the site-level nodes. `livePublic` marks whether the target is reachable at a * public URL right now, gating the URL-based validators. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function get_schema_preview( \WP_REST_Request $request ) { $schema = new Schema( $this->settings ); $seo = $schema->seo_plugin_active(); $enabled = $this->settings->enabled( 'enable_schema' ); $post_id = absint( $request->get_param( 'post' ) ); $post = null; $post_included = true; $post_note = ''; $live_public = true; // The site view is always reachable at a public URL. $target = array( 'type' => 'site', 'id' => 0, 'label' => __( 'Site-wide identity', 'agentimus' ), 'url' => home_url( '/' ), ); if ( $post_id > 0 ) { $post = get_post( $post_id ); if ( ! $post || ! in_array( $post->post_type, Content::post_types(), true ) ) { return new \WP_Error( 'agentimus_preview_not_found', __( 'That content is not available to preview.', 'agentimus' ), array( 'status' => 404 ) ); } $target = array( 'type' => 'post', 'id' => (int) $post->ID, 'label' => $this->preview_label( $post ), 'url' => (string) get_permalink( $post ), ); // Only a published, non-gated post is reachable at a public URL — the // precondition for the URL-based validators (a draft URL would 404). $live_public = ( 'publish' === $post->post_status && '' === (string) $post->post_password ); if ( '' !== (string) $post->post_password ) { // Never previewed: a gated body stays private even once published. $post_included = false; $post_note = __( 'This is password-protected, so its content is never exposed as schema — only the site-wide identity below.', 'agentimus' ); } elseif ( 'publish' !== $post->post_status ) { // Preview the node this post WILL emit once it's published — clearly // flagged as not-yet-live so the owner doesn't mistake it for live output. $post_note = __( 'Not published yet — this is a preview of the per-post schema that will ship once you publish. It isn’t in your live page’s yet.', 'agentimus' ); } } $doc = ( null === $post ) ? $schema->build_document( null, true ) : $schema->build_document( $post, false, true ); return rest_ensure_response( array( 'active' => (bool) ( $enabled && ! $seo ), 'reason' => ! $enabled ? 'disabled' : ( $seo ? 'seo_plugin' : 'ok' ), 'seoPlugin' => (bool) $seo, 'target' => $target, 'postIncluded' => (bool) $post_included, 'postNote' => $post_note, 'livePublic' => (bool) $live_public, 'graph' => $doc, // Pretty, slash-unescaped JSON for reading/validating. The live // escapes slashes to stay safe inside