'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' => '
', 'after_section' => '
', ) ); add_settings_field( 'aive_enable_robots_audit', __( 'Robots policy', 'summitcite' ), array( $this, 'render_enable_robots_field' ), self::PAGE_SLUG, self::SECTION_ROBOTS, array( 'label_for' => 'aive_enable_robots_audit' ) ); add_settings_field( 'aive_ai_bot_rules', __( 'AI bot rules', 'summitcite' ), array( $this, 'render_bot_rules_field' ), self::PAGE_SLUG, self::SECTION_ROBOTS ); // Wrapped in an anchor for the same reason the Robots section is. add_settings_section( self::SECTION_SCHEMA, __( 'Schema', 'summitcite' ), array( $this, 'render_schema_description' ), self::PAGE_SLUG, array( 'before_section' => '
', 'after_section' => '
', ) ); add_settings_field( 'aive_enable_schema', __( 'Schema Engine', 'summitcite' ), array( $this, 'render_enable_schema_field' ), self::PAGE_SLUG, self::SECTION_SCHEMA, array( 'label_for' => 'aive_enable_schema' ) ); // Wrapped in an anchor for the same reason the Robots section is. add_settings_section( self::SECTION_LLMS, __( 'llms.txt', 'summitcite' ), array( $this, 'render_llms_description' ), self::PAGE_SLUG, array( 'before_section' => '
', 'after_section' => '
', ) ); add_settings_field( 'aive_enable_llms_txt', __( 'llms.txt', 'summitcite' ), array( $this, 'render_enable_llms_field' ), self::PAGE_SLUG, self::SECTION_LLMS, array( 'label_for' => 'aive_enable_llms_txt' ) ); add_settings_field( 'aive_llms_txt_description', __( 'Site description', 'summitcite' ), array( $this, 'render_llms_description_field' ), self::PAGE_SLUG, self::SECTION_LLMS, array( 'label_for' => 'aive_llms_txt_description' ) ); add_settings_field( 'aive_llms_txt_pages', __( 'Content', 'summitcite' ), array( $this, 'render_llms_pages_field' ), self::PAGE_SLUG, self::SECTION_LLMS ); // Wrapped in an anchor for the same reason the Robots section is. add_settings_section( self::SECTION_ANSWER_DENSITY, __( 'Answer Density', 'summitcite' ), array( $this, 'render_answer_density_description' ), self::PAGE_SLUG, array( 'before_section' => '
', 'after_section' => '
', ) ); add_settings_field( 'aive_enable_answer_density', __( 'Answer Density', 'summitcite' ), array( $this, 'render_enable_answer_density_field' ), self::PAGE_SLUG, self::SECTION_ANSWER_DENSITY, array( 'label_for' => 'aive_enable_answer_density' ) ); add_settings_field( 'aive_answer_density_post_types', __( 'Scored post types', 'summitcite' ), array( $this, 'render_answer_density_post_types_field' ), self::PAGE_SLUG, self::SECTION_ANSWER_DENSITY ); // Wrapped in an anchor for the same reason the Robots section is. add_settings_section( self::SECTION_CONFLICT_DETECTOR, __( 'Conflict Detector', 'summitcite' ), array( $this, 'render_conflict_detector_description' ), self::PAGE_SLUG, array( 'before_section' => '
', 'after_section' => '
', ) ); add_settings_field( 'aive_enable_conflict_detector', __( 'Conflict Detector', 'summitcite' ), array( $this, 'render_enable_conflict_detector_field' ), self::PAGE_SLUG, self::SECTION_CONFLICT_DETECTOR, array( 'label_for' => 'aive_enable_conflict_detector' ) ); } /** * Reduces a submitted settings array to a complete, safe settings array. * * Starts from the effective stored settings so that every key survives, * then overwrites only the keys the form governs. A submission that is not * an array at all changes nothing. * * The Crawler Log fields are treated asymmetrically on purpose. An unchecked * checkbox is not submitted by the browser at all, so absence has to mean * false or the toggle could never be switched off. A number field always * submits, so absence of `log_retention_days` means the form did not govern * it on this request and the stored value stands - which is what keeps a * programmatic partial update from resetting the retention period. * * The Robots section is governed as a unit, keyed off its hidden marker, so * that a submission which never rendered the section cannot switch the * module off or rewrite the rule map by omission. `options.php` has already * unslashed everything in `$input` before the Settings API calls this, so * nothing here unslashes a second time - doing so would eat a legitimate * backslash. * * @since 1.1.0 * * @param array|mixed $input Submitted values. * @return array Complete settings array. */ public static function sanitize( $input ) { $settings = Helpers::get_settings(); if ( ! is_array( $input ) ) { return $settings; } $settings['enable_crawler_log'] = ! empty( $input['enable_crawler_log'] ); if ( array_key_exists( 'log_retention_days', $input ) ) { $settings['log_retention_days'] = CrawlerLog::sanitize_retention_days( $input['log_retention_days'] ); } if ( array_key_exists( self::ROBOTS_MARKER, $input ) ) { $settings['enable_robots_audit'] = ! empty( $input['enable_robots_audit'] ); $settings['ai_bot_rules'] = self::sanitize_bot_rules( isset( $input['ai_bot_rules'] ) ? $input['ai_bot_rules'] : array() ); } if ( array_key_exists( self::SCHEMA_MARKER, $input ) ) { $settings['enable_schema'] = ! empty( $input['enable_schema'] ); } if ( array_key_exists( self::LLMS_MARKER, $input ) ) { $settings['enable_llms_txt'] = ! empty( $input['enable_llms_txt'] ); $settings['llms_txt_description'] = LlmsText::plain( isset( $input['llms_txt_description'] ) && is_scalar( $input['llms_txt_description'] ) ? $input['llms_txt_description'] : '', LlmsDocument::DESCRIPTION_LIMIT ); /* * Stored already reduced to ids that can currently produce a line: * positive, deduplicated in submitted order, and eligible. A * malformed array, an unknown id or a record the document may not * describe is discarded rather than kept as a selection that would * never appear. */ $settings['llms_txt_pages'] = LlmsSelection::sanitize_ids( isset( $input['llms_txt_pages'] ) ? $input['llms_txt_pages'] : array() ); } if ( array_key_exists( self::ANSWER_DENSITY_MARKER, $input ) ) { $settings['enable_answer_density'] = ! empty( $input['enable_answer_density'] ); $settings['answer_density_post_types'] = self::sanitize_answer_density_post_types( isset( $input['answer_density_post_types'] ) ? $input['answer_density_post_types'] : array() ); } if ( array_key_exists( self::CONFLICT_DETECTOR_MARKER, $input ) ) { $settings['enable_conflict_detector'] = ! empty( $input['enable_conflict_detector'] ); } return $settings; } /** * Reduces a submitted post type list to currently allowed type names. * * Invalid, non-public and explicitly disallowed types are discarded rather * than kept inert, so the stored setting is always a safe input to the * eligibility check that reads it. * * @since 1.5.0 * * @param array|mixed $submitted Submitted type names. * @return array Allowed type names, deduplicated. */ private static function sanitize_answer_density_post_types( $submitted ) { $submitted = is_array( $submitted ) ? $submitted : array(); $types = array(); foreach ( $submitted as $type ) { if ( ! is_scalar( $type ) ) { continue; } $type = sanitize_key( (string) $type ); if ( AnswerDensity::is_allowed_post_type( $type ) && ! in_array( $type, $types, true ) ) { $types[] = $type; } } return $types; } /** * Reduces a submitted rule map to one rule per currently registered bot. * * Rebuilt from the registry rather than filtered from the submission, which * decides three cases at once: a token that is not in the registry is * discarded however it was submitted, a token whose value is missing or not * one of the three recognised rules becomes Don't manage, and a token the * registry carries but the form did not render still ends up with a rule. * The result is the stored map, so a token that has left the registry is * also dropped from storage by this save. * * @since 1.2.0 * * @param array|mixed $submitted Submitted rule map. * @return array Rule keyed by registry name. */ private static function sanitize_bot_rules( $submitted ) { $submitted = is_array( $submitted ) ? $submitted : array(); $rules = array(); foreach ( Helpers::effective_bot_rules() as $token => $current ) { $value = isset( $submitted[ $token ] ) && is_scalar( $submitted[ $token ] ) ? sanitize_key( (string) $submitted[ $token ] ) : ''; $rules[ $token ] = in_array( $value, Helpers::bot_rules(), true ) ? $value : Helpers::RULE_NONE; } return $rules; } /** * Renders the settings page. * * The capability check is repeated here because a page callback is reachable * by URL independently of the menu that registered it: the menu controls who * is shown the link, not who may request the screen. * * @since 1.1.0 * * @return void */ public function render() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to manage SummitCite settings.', 'summitcite' ), '', array( 'response' => 403 ) ); } ?>

%s

', esc_html__( 'This build ships four modules: the Crawler Log, which measures AI crawler visits; the Robots policy, which declares which AI bots may read the site; the Schema Engine, which describes eligible content in JSON-LD; and llms.txt, which publishes a plain-text inventory of the public content you choose. Controls for the remaining modules appear here as those modules are released.', 'summitcite' ) ); } /** * Renders the Crawler Log section description. * * @since 1.1.0 * * @return void */ public function render_crawler_log_description() { printf( '

%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( '

%2$s

', 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(); ?> $rule ) : ?>

%s

', esc_html__( 'Allow adds an Allow rule for the site with the admin area excluded. Block adds a Disallow for the whole site. Don\'t manage emits nothing for that bot, which leaves it to whatever the rest of robots.txt says. A bot an existing rule already names is left alone whatever is chosen here.', 'summitcite' ) ); } /** * Renders the Schema section description. * * States the two limits of the feature before the control, so that switching * it on is not read as a stronger promise than it is: it governs AIVE's own * output and nothing else, and correct markup is not an outcome. * * @since 1.3.0 * * @return void */ public function render_schema_description() { printf( '

%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( '

%2$s

', 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; } ?>


$ownership Ownership decision. * @param string $owner Owner identifier. * @return array|null Adapter record, or null when no plugin owns the route. */ private function llms_adapter( array $ownership, $owner ) { $adapters = isset( $ownership['adapters'] ) && is_array( $ownership['adapters'] ) ? $ownership['adapters'] : array(); foreach ( $adapters as $adapter ) { if ( isset( $adapter['id'] ) && (string) $adapter['id'] === $owner ) { return $adapter; } } return null; } /** * Renders one inline notice with a heading and a body. * * @since 1.4.0 * * @param string $type Core notice type: `info`, `success` or `warning`. * @param string $heading Short translated heading. * @param string $body Translated body text. * @return void */ private function render_llms_notice( $type, $heading, $body ) { printf( '

%2$s %3$s

', esc_attr( $type ), esc_html( $heading ), esc_html( $body ) ); } /** * Renders the llms.txt enable checkbox. * * The hidden marker is emitted first and unconditionally, because it is what * tells the sanitiser this section was really submitted. * * @since 1.4.0 * * @return void */ public function render_enable_llms_field() { printf( '', esc_attr( Helpers::OPTION_KEY ), esc_attr( self::LLMS_MARKER ) ); $enabled = (bool) Helpers::get_setting( 'enable_' . LlmsTxt::SLUG, true ); printf( '', esc_attr( Helpers::OPTION_KEY ), checked( $enabled, true, false ), esc_html__( 'Serve a generated llms.txt at the site root', 'summitcite' ) ); printf( '

%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( '

%2$s

', 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( '

%2$s

', 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

', esc_html__( 'Excluded:', 'summitcite' ), esc_html( _n( 'One entry in the crawler registry carries a name that cannot be written into robots.txt, so it has no rule here and nothing is emitted for it.', 'Some entries in the crawler registry carry names that cannot be written into robots.txt, so they have no rule here and nothing is emitted for them.', count( $invalid ), 'summitcite' ) ), esc_html( implode( ', ', $invalid ) ) ); } /** * Returns the translated label for each rule value. * * Public because the Robots Audit screen reports the same configured rules * and must name them with the same words the editor uses. * * @since 1.2.0 * * @return array Label keyed by rule value. */ public static function rule_labels() { return array( Helpers::RULE_ALLOW => __( 'Allow', 'summitcite' ), Helpers::RULE_BLOCK => __( 'Block', 'summitcite' ), Helpers::RULE_NONE => __( 'Don\'t manage', 'summitcite' ), ); } /** * Returns the translated label for a registry bot type. * * @since 1.2.0 * * Public for the same reason as rule_labels(). * * @param string $type Registry type slug. * @return string Translated label, or the raw slug when unrecognised. */ public static function type_label( $type ) { switch ( $type ) { case CrawlerList::TYPE_SEARCH: return __( 'Search', 'summitcite' ); case CrawlerList::TYPE_ASSISTANT: return __( 'Assistant', 'summitcite' ); case CrawlerList::TYPE_TRAINING: return __( 'Training', 'summitcite' ); } return (string) $type; } }