'array', 'sanitize_callback' => array( self::class, 'sanitize' ), 'default' => Helpers::default_settings(), /* * The option holds per-module configuration for an admin-only * screen and has no REST consumer, so it is not exposed there. */ 'show_in_rest' => false, ) ); add_settings_section( 'aive_section_general', __( 'General', 'summitcite' ), array( $this, 'render_general_description' ), self::PAGE_SLUG ); add_settings_field( 'aive_enable_crawler_log', __( 'Crawler Log', 'summitcite' ), array( $this, 'render_enable_crawler_log_field' ), self::PAGE_SLUG, 'aive_section_general', array( 'label_for' => 'aive_enable_crawler_log' ) ); add_settings_section( 'aive_section_crawler_log', __( 'Crawler Log', 'summitcite' ), array( $this, 'render_crawler_log_description' ), self::PAGE_SLUG ); add_settings_field( 'aive_log_retention_days', __( 'Keep visits for', 'summitcite' ), array( $this, 'render_log_retention_days_field' ), self::PAGE_SLUG, 'aive_section_crawler_log', array( 'label_for' => 'aive_log_retention_days' ) ); /* * Wrapped in an element carrying the section id, because the Robots * Audit screen links here by fragment and `do_settings_sections()` * otherwise emits no anchor to land on - the heading it prints has no * id of its own. `before_section` and `after_section` exist for exactly * this and are available on the supported WordPress floor. */ add_settings_section( self::SECTION_ROBOTS, __( 'Robots', 'summitcite' ), array( $this, 'render_robots_description' ), self::PAGE_SLUG, array( 'before_section' => '
%s
', esc_html__( 'Turning the Crawler Log off stops new visits being recorded. Visits already recorded are kept and still expire on the schedule below; turning it back on resumes recording from that point, and does not recover visits that happened while it was off.', 'summitcite' ) ); } /** * Renders the Crawler Log enable checkbox. * * @since 1.1.0 * * @return void */ public function render_enable_crawler_log_field() { $enabled = (bool) Helpers::get_setting( 'enable_crawler_log', true ); printf( '', esc_attr( Helpers::OPTION_KEY ), checked( $enabled, true, false ), esc_html__( 'Record visits from known AI crawlers', 'summitcite' ) ); printf( '%s
', esc_html__( 'Identification is based on the User-Agent header a request sends, which any client can set, so a recorded visit is a claim rather than a verified identity. Requests served by a CDN, a reverse proxy or a page cache never reach WordPress and cannot be recorded at all.', 'summitcite' ) ); } /** * Renders the retention period field. * * @since 1.1.0 * * @return void */ public function render_log_retention_days_field() { printf( ' %5$s', esc_attr( Helpers::OPTION_KEY ), esc_attr( CrawlerLog::retention_days() ), esc_attr( CrawlerLog::RETENTION_MIN ), esc_attr( CrawlerLog::RETENTION_MAX ), esc_html__( 'days', 'summitcite' ) ); printf( '%s
', esc_html( sprintf( /* translators: 1: minimum retention in days, 2: maximum retention in days. */ __( '%1$d-%2$d days. A daily task deletes visits older than this, including the IP address recorded with them. A value outside the range is reduced to the nearest limit.', 'summitcite' ), CrawlerLog::RETENTION_MIN, CrawlerLog::RETENTION_MAX ) ) ); } /** * Renders the Robots section description. * * States the three limits of the section before any control is shown, so * that a rule saved here is not read as a stronger promise than it is: it * governs generated output only, it stands down when something else owns * robots.txt, and some tokens carry a rule without ever being observable in * the crawler log. * * @since 1.2.0 * * @return void */ public function render_robots_description() { printf( '%s
', esc_html__( 'These rules govern the robots.txt that WordPress generates, and nothing else. They are inactive whenever another system owns robots.txt - a file in the site root, an SEO plugin with its own robots editor switched on - or whenever WordPress is set to discourage search engines.', 'summitcite' ) ); printf( '%s
', esc_html__( 'A rule is a declaration, not enforcement: it is honoured by the crawlers that choose to honour robots.txt. Some tokens exist only to carry a robots.txt rule and send no User-Agent of their own, so they will never appear in the crawler log however the rule is set.', 'summitcite' ) ); printf( '', esc_url( admin_url( 'admin.php?page=' . RobotsAuditPage::PAGE_SLUG ) ), esc_html__( 'Open Robots Audit to see what the live robots.txt grants each bot', 'summitcite' ) ); } /** * Renders the robots module enable checkbox. * * @since 1.2.0 * * @return void */ public function render_enable_robots_field() { $enabled = (bool) Helpers::get_setting( 'enable_' . RobotsPolicy::SLUG, true ); printf( '', esc_attr( Helpers::OPTION_KEY ), checked( $enabled, true, false ), esc_html__( 'Add the rules below to the robots.txt WordPress generates', 'summitcite' ) ); printf( '%s
', esc_html__( 'Switching this off removes AIVE\'s block from robots.txt on the next request and leaves the rest of the response untouched. The saved rules are kept.', 'summitcite' ) ); } /** * Renders the per-bot rule table. * * The hidden marker is emitted first and unconditionally, because it is what * tells the sanitiser this section was really submitted. Rows are built from * the effective rule map, so the table shows exactly the tokens that can be * emitted - no more, and no stale token from an earlier registry. * * No JavaScript is involved: the control is a native select inside the * Settings API form, and the screen works with scripting disabled. * * @since 1.2.0 * * @return void */ public function render_bot_rules_field() { printf( '', esc_attr( Helpers::OPTION_KEY ), esc_attr( self::ROBOTS_MARKER ) ); $this->render_invalid_registry_notice(); $rules = Helpers::effective_bot_rules(); $labels = self::rule_labels(); ?>|
|
%s
', esc_html__( 'AIVE can describe eligible published content in JSON-LD so that search engines and AI systems read the page\'s facts from structured data. It stands aside whenever evidence shows an SEO plugin is already emitting content-level structured data, so the two never duplicate each other.', 'summitcite' ) ); printf( '%s
', esc_html__( 'Emitting correct structured data does not make a page eligible for a rich result, does not affect ranking and does not guarantee that any AI system cites the site.', 'summitcite' ) ); printf( '', esc_url( admin_url( 'admin.php?page=' . SchemaPage::PAGE_SLUG ) ), esc_html__( 'Open Schema to see the generation rules and preview a record', 'summitcite' ) ); } /** * Renders the schema module enable checkbox. * * The hidden marker is emitted first and unconditionally, because it is what * tells the sanitiser this section was really submitted. * * @since 1.3.0 * * @return void */ public function render_enable_schema_field() { printf( '', esc_attr( Helpers::OPTION_KEY ), esc_attr( self::SCHEMA_MARKER ) ); $enabled = (bool) Helpers::get_setting( 'enable_' . SchemaEngine::SLUG, true ); printf( '', esc_attr( Helpers::OPTION_KEY ), checked( $enabled, true, false ), esc_html__( 'Emit JSON-LD for eligible published content', 'summitcite' ) ); printf( '%s
', esc_html__( 'Switching this off stops AIVE emitting on the next request, makes the generated preview unavailable and clears the schema cache. Structured data from any other plugin or theme is untouched either way.', 'summitcite' ) ); } /** * Renders the llms.txt section description and the current route state. * * States the limits of the feature before any control, then reports what is * actually happening right now. The state report is the part that matters in * use: the controls below can be set correctly while something else owns the * route, and an administrator needs to be told which, by name, rather than * left to discover it by requesting the URL. * * @since 1.4.0 * * @return void */ public function render_llms_description() { printf( '%s
', esc_html__( 'AIVE can serve a plain-text llms.txt at the site root listing your site name, a short description and the public content you choose, so that AI answer engines can read an inventory of the site without crawling it.', 'summitcite' ) ); printf( '%s
', esc_html__( 'Publishing the file does not make any AI system read, index, quote or rank the site, and AIVE sends nothing anywhere: the document is generated on request from content the site already publishes. The route needs a permalink structure other than Plain, because it is served through a WordPress rewrite rule.', 'summitcite' ) ); $this->render_llms_state_notice(); $this->render_llms_adapter_status(); } /** * Reports who owns the llms.txt route, and what to do about it. * * One neutral notice per state, naming the evidence and a safe action. What * it never says is that another owner is successfully serving a valid file: * a physical file is served by the web server before PHP runs and a plugin's * output can be intercepted by a cache or a CDN, so neither is observable * from here. The evidence establishes only that AIVE will not generate a * competing document. * * @since 1.4.0 * * @return void */ private function render_llms_state_notice() { $state = LlmsTxt::state(); $ownership = $state['ownership']; $owner = isset( $ownership['owner'] ) ? (string) $ownership['owner'] : ''; if ( LlmsOwnership::OWNER_PRIVATE_SITE === $owner ) { $this->render_llms_notice( 'warning', __( 'Site visibility is off.', 'summitcite' ), __( 'WordPress is set to discourage search engines from indexing this site, so AIVE generates no llms.txt. Restore site visibility under Settings > Reading if AIVE should publish one. A file that a web server serves from the site root is unaffected by this setting and is not AIVE output.', 'summitcite' ) ); return; } if ( LlmsOwnership::OWNER_PHYSICAL_FILE === $owner ) { $this->render_llms_notice( 'info', __( 'A file already exists at the site root.', 'summitcite' ), sprintf( /* translators: %s: absolute filesystem path of the existing file. */ __( 'AIVE generates nothing while %s exists, so the two cannot produce two documents. Manage the file where it is created - a plugin, your host or your own deployment - or remove it if AIVE should generate the document instead. AIVE cannot see what a web server, cache or CDN actually serves for that path and makes no claim about it.', 'summitcite' ), isset( $ownership['file'] ) ? (string) $ownership['file'] : 'llms.txt' ) ); return; } $adapter = $this->llms_adapter( $ownership, $owner ); if ( null !== $adapter ) { $this->render_llms_notice( 'info', sprintf( /* translators: %s: plugin name. */ __( '%s is set to provide llms.txt.', 'summitcite' ), $adapter['name'] ), sprintf( /* translators: 1: plugin name, 2: where the setting lives in that plugin, 3: the stored setting consulted. */ __( 'AIVE generates nothing while that plugin\'s feature is switched on, so the two cannot produce two documents. Manage it at %2$s, or switch it off there if AIVE should generate the document instead. The evidence is the stored setting %3$s; AIVE does not verify that %1$s is successfully serving a valid file.', 'summitcite' ), $adapter['name'], $adapter['action'], $adapter['setting'] ) ); return; } if ( ! $state['enabled'] ) { $this->render_llms_notice( 'info', __( 'llms.txt is switched off.', 'summitcite' ), __( 'AIVE generates no document and its route returns the site\'s ordinary Not Found response. Nothing here asserts that anything else provides one.', 'summitcite' ) ); return; } $this->render_llms_notice( 'success', __( 'AIVE owns this route.', 'summitcite' ), sprintf( /* translators: %s: the public URL of the generated document. */ __( 'No file in the site root and no supported plugin is set to provide llms.txt, so AIVE generates the document at %s.', 'summitcite' ), home_url( '/' . LlmsTxt::ROUTE ) ) ); } /** * Reports each supported plugin's status without overstating it. * * Rendered whatever the decision above was, because the two answer different * questions: that notice says who owns the route, and this says what AIVE * could and could not establish about each plugin it knows how to read. A * plugin whose signal is absent, unreadable or outside what this build * supports is reported as not established - which is not a claim that it is * serving anything, and not a reason AIVE stands down. * * @since 1.4.0 * * @return void */ private function render_llms_adapter_status() { $adapters = LlmsOwnership::evaluate_adapters(); $active = array(); foreach ( $adapters as $adapter ) { if ( ! empty( $adapter['active'] ) ) { $active[] = $adapter; } } if ( array() === $active ) { return; } ?>|
|
|
|---|
%2$s %3$s
%s
', esc_html__( 'Switching this off stops AIVE serving on the next request, so the route returns the site\'s ordinary Not Found response, and clears the cached document. No file is written, edited or deleted either way, and nothing another plugin serves is affected.', 'summitcite' ) ); } /** * Renders the llms.txt description textarea. * * @since 1.4.0 * * @return void */ public function render_llms_description_field() { $value = Helpers::get_setting( 'llms_txt_description', '' ); printf( '', esc_attr( Helpers::OPTION_KEY ), esc_attr( LlmsDocument::DESCRIPTION_LIMIT ), esc_textarea( is_scalar( $value ) ? (string) $value : '' ) ); printf( '%s
', esc_html( sprintf( /* translators: %d: maximum number of characters. */ __( 'One short sentence describing the site, used as the document\'s summary line. Plain text only, up to %d characters; markup and line breaks are removed. Leave it empty to use the site tagline, and the line is omitted entirely when both are empty.', 'summitcite' ), LlmsDocument::DESCRIPTION_LIMIT ) ) ); } /** * Renders the bounded content checklist. * * A native checkbox group inside the Settings API form: no JavaScript, no * AJAX and no custom endpoint. The three groups are separate fieldsets with * their own legends, so the relationship between a heading and the controls * under it is in the markup rather than only in the visual layout. * * @since 1.4.0 * * @return void */ public function render_llms_pages_field() { printf( '%s
', esc_html( sprintf( /* translators: 1: maximum automatic pages, 2: maximum automatic posts. */ __( 'Leave every box clear for automatic selection: up to %1$d pages, then up to %2$d of the most recent posts. Tick anything and the document lists exactly your selection instead, in the order shown here, with no automatic entries added.', 'summitcite' ), LlmsSelection::AUTOMATIC_PAGES, LlmsSelection::AUTOMATIC_POSTS ) ) ); $choices = LlmsSelection::admin_choices(); $selected = LlmsSelection::stored_ids(); $groups = array( 'pages' => __( 'Pages', 'summitcite' ), 'posts' => __( 'Recent posts', 'summitcite' ), 'other' => __( 'Also selected', 'summitcite' ), ); $rendered = 0; foreach ( $groups as $group => $legend ) { if ( array() === $choices[ $group ] ) { continue; } ++$rendered; ?> %s', esc_html__( 'This site has no published content the document could describe yet, so there is nothing to choose and nothing would be listed.', 'summitcite' ) ); } printf( '%s
', esc_html( sprintf( /* translators: 1: maximum pages offered, 2: maximum recent posts offered, 3: maximum document size in kibibytes. */ __( 'The lists above are bounded at %1$d pages and %2$d posts; anything already selected outside them appears under Also selected and is kept when you save. A selected record that is later unpublished, password-protected or deleted simply stops appearing in the document, and your selection is left as it is. Pages are listed under Pages and everything else under Posts. The whole document is capped at %3$d KiB, and items past that cap are left out rather than cut short.', 'summitcite' ), LlmsSelection::ADMIN_PAGE_CHOICES, LlmsSelection::ADMIN_POST_CHOICES, (int) ( LlmsDocument::MAX_BYTES / 1024 ) ) ) ); } /** * Renders the Answer Density section description. * * @since 1.5.0 * * @return void */ public function render_answer_density_description() { printf( '%s
', esc_html__( 'AIVE can measure how structurally extractable a published item\'s answers are: how much of its heading structure reads as a question, and how much of that is answered immediately rather than buried further down. The score is a deterministic, local heuristic about structure only - not a judgement of factual accuracy or writing quality, not a prediction of how any AI system will treat the page, and not a ranking or citation guarantee.', 'summitcite' ) ); printf( '', esc_url( admin_url( 'admin.php?page=' . AnswerDensityPage::PAGE_SLUG ) ), esc_html__( 'Open Answer Density to scan content and see the worst-scoring items', 'summitcite' ) ); } /** * Renders the Answer Density module enable checkbox. * * The hidden marker is emitted first and unconditionally, for the same * reason the Schema and llms.txt enable fields do: it is what tells the * sanitiser this section was really submitted. * * @since 1.5.0 * * @return void */ public function render_enable_answer_density_field() { printf( '', esc_attr( Helpers::OPTION_KEY ), esc_attr( self::ANSWER_DENSITY_MARKER ) ); $enabled = (bool) Helpers::get_setting( 'enable_' . AnswerDensity::SLUG, true ); printf( '', esc_attr( Helpers::OPTION_KEY ), checked( $enabled, true, false ), esc_html__( 'Score the configured post types for answer density', 'summitcite' ) ); printf( '%s
', esc_html__( 'Switching this off stops every automatic, scheduled and on-demand scan, and hides score claims on the Answer Density screen. Stored results are kept and reappear unchanged when this is switched back on.', 'summitcite' ) ); } /** * Renders the scored post types checklist. * * @since 1.5.0 * * @return void */ public function render_answer_density_post_types_field() { $selected = AnswerDensity::configured_post_types(); printf( '%s
', esc_html__( 'Only a publicly queryable post type can be chosen. Removing a type here does not delete its posts\' stored scores; it only stops new scans and hides those posts from this screen and its summary until the type is added back.', 'summitcite' ) ); foreach ( AnswerDensity::allowed_post_types() as $type ) { $object = get_post_type_object( $type ); $label = null === $object ? $type : $object->labels->singular_name; $field_id = 'aive_answer_density_type_' . sanitize_key( $type ); printf( '', esc_attr( $field_id ), esc_attr( Helpers::OPTION_KEY ), esc_attr( $type ), checked( in_array( $type, $selected, true ), true, false ), esc_html( (string) $label ) ); } } /** * Renders the Conflict Detector section description. * * @since 1.6.0 * * @return void */ public function render_conflict_detector_description() { printf( '%s
', esc_html__( 'AIVE can report, in one place, which supported SEO plugins are active, whether each of the robots policy, schema and llms.txt features is handling its own output or standing aside for evidenced ownership elsewhere, and a safe next action. It decides nothing on its own: every row is read from that feature\'s own ownership decision.', 'summitcite' ) ); printf( '', esc_url( admin_url( 'admin.php?page=' . ConflictDetectorPage::PAGE_SLUG ) ), esc_html__( 'Open Conflict Detector to see the plugin inventory, feature status and recommendations', 'summitcite' ) ); } /** * Renders the Conflict Detector enable checkbox. * * The hidden marker is emitted first and unconditionally, for the same * reason the Schema, llms.txt and Answer Density enable fields do: it is * what tells the sanitiser this section was really submitted. * * @since 1.6.0 * * @return void */ public function render_enable_conflict_detector_field() { printf( '', esc_attr( Helpers::OPTION_KEY ), esc_attr( self::CONFLICT_DETECTOR_MARKER ) ); $enabled = (bool) Helpers::get_setting( 'enable_' . ConflictDetector::SLUG, true ); printf( '', esc_attr( Helpers::OPTION_KEY ), checked( $enabled, true, false ), esc_html__( 'Report supported-plugin presence and feature ownership status in one place', 'summitcite' ) ); printf( '%s
', esc_html__( 'Switching this off removes the Conflict Detector menu entry and stops its report, cache and statistics on the next request. The robots policy, schema and llms.txt features are completely unaffected either way; this only controls whether AIVE also reports on them in one place.', 'summitcite' ) ); } /** * Warns that a filtered registry entry cannot be used in robots.txt. * * @since 1.2.0 * * @return void */ private function render_invalid_registry_notice() { $invalid = Helpers::invalid_registry_tokens(); if ( array() === $invalid ) { return; } printf( '%1$s %2$s
%3$s