'GET', 'callback' => 'citami_rest_ping', 'permission_callback' => 'citami_check_permission', )); register_rest_route('citami/v1', '/apply-fix', array( 'methods' => 'POST', 'callback' => 'citami_rest_apply_fix', 'permission_callback' => 'citami_check_permission', )); // v2.8.0 — PUBLISH-VIA-PLUGIN. Il Content Agent pubblicava via // REST core di WP con username + Application Password: crearla // (Utenti → Profilo → scorri → Add New → copia 24 caratteri) è il // passaggio dove un cliente non tecnico si perde. Il plugin è GIÀ // installato e autenticato con la API key del sito: questo // endpoint pubblica l'articolo senza credenziali aggiuntive. La // dashboard lo prova per prima e ricade sulle Application // Password solo se il plugin è vecchio o assente. register_rest_route('citami/v1', '/publish-article', array( 'methods' => 'POST', 'callback' => 'citami_rest_publish_article', 'permission_callback' => 'citami_check_permission', )); }); /** * v2.8.0 — crea un post WordPress dall'articolo generato dal Content * Agent. Risponde con {id, link}: la stessa shape minima che il * server già consuma dalla REST core (/wp/v2/posts), così il * fallback resta simmetrico. */ function citami_rest_publish_article(WP_REST_Request $request) { // 2.51 — the post is created by citami_publish_article_payload (includes/articles.php), // shared with the plugin's own Articles tab so both paths produce the same post. $data = array(); foreach (array('title', 'content', 'status', 'excerpt', 'slug', 'author', 'categories', 'schema_jsonld') as $k) { $data[$k] = $request->get_param($k); } $post = citami_publish_article_payload($data); if (is_wp_error($post)) { return $post; } return rest_ensure_response(array('id' => (int) $post['id'], 'link' => (string) $post['link'])); } /** * v2.9.0 — CONTENT-IMAGES. Gli articoli del Content Agent arrivano con * le immagini ospitate sullo storage Citami (URL .../content-images/...). * Senza questo passaggio il post pubblicato referenzia immagini esterne: * non entrano nella Libreria Media e non c'è immagine in evidenza * (segnalato da Adrian su hero.png). Qui le scarichiamo nella Libreria * via media_sideload_image(), riscriviamo gli URL nel contenuto e * impostiamo la prima (l'hero, sempre in cima) come featured. * * Best-effort a ogni passo: una singola immagine che fallisce il * download lascia il suo URL esterno e non blocca le altre né la * pubblicazione. Ritorna il contenuto (riscritto se almeno un'immagine * è stata importata, identico altrimenti). */ function citami_sideload_post_images($post_id, $content) { $content = (string) $content; // Marker degli URL Citami — stesso di lib/content-agent/wp-publish.ts // (OWN_IMAGE_MARKER). Copre sia gli URL storage Supabase sia i path // relativi /content-images/. if (strpos($content, '/content-images/') === false) { return $content; } // media_sideload_image e le sue dipendenze vivono in wp-admin/includes // e non sono caricate nel contesto REST: includiamole on-demand. if (!function_exists('media_sideload_image') && defined('ABSPATH')) { require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; } if (!preg_match_all('/]*>/i', $content, $tags)) { return $content; } // 2.54.2 — file named after the article (not hero.webp / citami-123.png), // alt text copied into the media library, title = alt. $post = get_post($post_id); $base = $post && !empty($post->post_name) ? (string) $post->post_name : ''; if ($base === '' && $post && !empty($post->post_title)) { $base = sanitize_title((string) $post->post_title); } if ($base === '') { $base = 'article-' . (int) $post_id; } $seen = array(); $first_attachment = 0; $first_url = ''; $n = 0; foreach ($tags[0] as $tag) { if (!preg_match('/\ssrc=["\']([^"\']+)["\']/i', $tag, $m)) { continue; } $url = html_entity_decode($m[1], ENT_QUOTES); if (strpos($url, '/content-images/') === false || isset($seen[$url])) { continue; } $seen[$url] = true; $alt = preg_match('/\salt=["\']([^"\']*)["\']/i', $tag, $a) ? trim(html_entity_decode($a[1], ENT_QUOTES)) : ''; $n++; $attachment_id = citami_sideload_one_image($url, (int) $post_id, $base . ($n > 1 ? '-' . $n : ''), $alt); if ($attachment_id <= 0) { continue; } $new_url = wp_get_attachment_url($attachment_id); if (is_string($new_url) && $new_url !== '') { $content = str_replace($m[1], $new_url, $content); } if ($first_attachment === 0) { $first_attachment = $attachment_id; $first_url = is_string($new_url) ? $new_url : ''; } } if ($first_attachment > 0 && !has_post_thumbnail($post_id)) { set_post_thumbnail($post_id, $first_attachment); // The hero opens the body AND becomes the featured image: most themes // print the featured image above the content, so it showed twice. $content = citami_strip_leading_image($content, $first_url); } return $content; } /** One image into the Media Library with a chosen file name and alt text. * Returns the attachment id, 0 on failure. */ function citami_sideload_one_image($url, $post_id, $name, $alt) { $path = (string) wp_parse_url($url, PHP_URL_PATH); $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if (!in_array($ext, array('webp', 'png', 'jpg', 'jpeg', 'gif'), true)) { $ext = 'webp'; } $desc = $alt !== '' ? $alt : null; if (function_exists('download_url') && function_exists('media_handle_sideload')) { $tmp = download_url($url); if (is_wp_error($tmp)) { return 0; } $file = array('name' => sanitize_file_name($name . '.' . $ext), 'tmp_name' => $tmp); $id = media_handle_sideload($file, $post_id, $desc); if (is_wp_error($id)) { if (is_string($tmp) && file_exists($tmp)) { wp_delete_file($tmp); } return 0; } } else { $id = media_sideload_image($url, $post_id, $desc, 'id'); if (is_wp_error($id) || empty($id)) { return 0; } } $id = (int) $id; if ($alt !== '') { update_post_meta($id, '_wp_attachment_image_alt', $alt); } return $id; } /** Remove the image that opens the content (as ,

or a *
) when it is the given URL. Anything else is left untouched. */ function citami_strip_leading_image($content, $url) { if ($url === '') { return $content; } $re = '~^\s*(?:

\s*]*>\s*

|]*>.*?
|]*>)\s*~is'; if (preg_match($re, $content, $m) && strpos($m[0], $url) !== false) { return (string) substr($content, strlen($m[0])); } return $content; } /** * v2.8.0 — renderizza in lo schema JSON-LD dell'articolo * (post meta _citami_schema_jsonld) sui singoli post. Bonus trovato * implementando publish-article: il meta veniva scritto ma NESSUNO * lo emetteva in pagina — e nel percorso Application Password la * REST core lo scartava pure (meta protetto non registrato). Output * solo se il JSON e' valido: niente markup rotto in head. */ add_action('wp_head', function () { if (!is_singular('post')) { return; } $schema = get_post_meta(get_the_ID(), '_citami_schema_jsonld', true); if (!is_string($schema) || $schema === '') { return; } $decoded = json_decode($schema, true); if (!is_array($decoded)) { return; } $out = citami_article_schema_for_head($decoded, get_post(get_the_ID())); if ($out === null) { return; } echo "\n\n"; // v2.11.1 — JSON_HEX_TAG: "" nel testo non può chiudere il tag. wp_print_inline_script_tag((string) wp_json_encode($out, JSON_HEX_TAG), array('type' => 'application/ld+json')); }, 22); /** * 2.54.2 — the schema stored with a Content Agent article was built before * the post existed: placeholder image, generation date, invented breadcrumb * paths. When an engine already describes the post (ours, Yoast, Rank Math…) * its Article and BreadcrumbList win and only the extra nodes (FAQPage…) are * kept; otherwise the Article gets the post's real URL, dates and featured * image and the made-up breadcrumb is dropped. Null when nothing is left. */ function citami_article_schema_for_head(array $decoded, $post) { $nodes = isset($decoded['@graph']) && is_array($decoded['@graph']) ? $decoded['@graph'] : array($decoded); $other_engine = (function_exists('citami_schema_enabled') && citami_schema_enabled()) || (function_exists('citami_active_seo_plugin') && citami_active_seo_plugin() !== null); $article_types = array('Article', 'BlogPosting', 'NewsArticle', 'TechArticle'); $keep = array(); foreach ($nodes as $node) { if (!is_array($node)) { continue; } $type = isset($node['@type']) ? $node['@type'] : ''; $type = is_array($type) ? (string) reset($type) : (string) $type; unset($node['@context']); if ($type === 'BreadcrumbList') { continue; } if (in_array($type, $article_types, true)) { if ($other_engine || !$post) { continue; } $url = get_permalink($post); if (is_string($url) && $url !== '') { $node['url'] = $url; $node['mainEntityOfPage'] = $url; } if (!empty($post->post_date_gmt) && $post->post_date_gmt !== '0000-00-00 00:00:00') { $node['datePublished'] = gmdate('c', strtotime($post->post_date_gmt . ' UTC')); } if (!empty($post->post_modified_gmt) && $post->post_modified_gmt !== '0000-00-00 00:00:00') { $node['dateModified'] = gmdate('c', strtotime($post->post_modified_gmt . ' UTC')); } $img = function_exists('citami_schema_featured_image') ? citami_schema_featured_image($post) : ''; if ($img !== '') { $node['image'] = $img; } elseif (isset($node['image']) && is_string($node['image']) && strpos($node['image'], 'placeholder.example.com') !== false) { unset($node['image']); } } $keep[] = $node; } if (empty($keep)) { return null; } if (count($keep) === 1) { return array('@context' => 'https://schema.org') + $keep[0]; } return array('@context' => 'https://schema.org', '@graph' => $keep); } function citami_rest_ping(WP_REST_Request $request) { // v2.2.0+: surface the auto-apply preload state so the dashboard's // /api/dashboard/plugin/connection-status can show "Plugin in // modalità auto-apply" without a second round-trip. $preload_mode = defined('CITAMI_PRELOADED_AUTO_APPLY_MODE') ? (string) CITAMI_PRELOADED_AUTO_APPLY_MODE : ''; $auto_apply_preload = $preload_mode === 'enabled' ? 'enabled' : 'disabled'; return rest_ensure_response(array( 'ok' => true, 'version' => CITAMI_PLUGIN_VERSION, 'review_only' => citami_is_review_only(), 'auto_apply_preload' => $auto_apply_preload, )); } /** * Apply a single fix forwarded by Citami. The body is: * { fix_type, title, description, code_snippet } * * Behaviour: * - Always queues the raw fix in citami_fix_queue (audit trail). * - Rate-limited to 1 application/minute per site (DOS protection). * - In review-only mode (default ON): returns success but does NOT * write the fix to wp_head / llms.txt / robots.txt. * - When review-only is OFF, applies fixes by type: * llms_txt → write /llms.txt (validated path) * robots_txt → append rules to robots.txt filter * schema_injection → JSON-LD validated & re-encoded, queued for wp_head * meta_tags → wp_kses allow-list filter, queued for wp_head * anything else → queued for manual review only */ /** * REST entry point: rate limit + request parsing, then the shared core * (citami_apply_fix_payload) that the cron sync pull (WP1) reuses. */ function citami_rest_apply_fix(WP_REST_Request $request) { if (!citami_check_apply_rate()) { return new WP_Error( 'citami_rate_limit', 'Troppi fix applicati di recente. Riprova tra un minuto.', array('status' => 429) ); } $body = $request->get_json_params(); return citami_apply_fix_payload(is_array($body) ? $body : array(), citami_should_force_apply($request)); } /** * Core "apply one fix" logic. $body = the platform payload (fix_type, * title, description, code_snippet, fix_id). $force_apply bypasses the * review-only queue for this call. Returns a WP_REST_Response or a * WP_Error (bad payload / invalid JSON-LD). */ function citami_apply_fix_payload(array $body, $force_apply = false, $queue_only = false) { $fix_type = isset($body['fix_type']) ? sanitize_key($body['fix_type']) : ''; $title = isset($body['title']) ? sanitize_text_field($body['title']) : ''; // v2.13.0 — the platform's fix id (UUID). Optional for older callers. $fix_id = isset($body['fix_id']) ? sanitize_text_field((string) $body['fix_id']) : ''; $description = isset($body['description']) ? $body['description'] : ''; $code_snippet = isset($body['code_snippet']) ? $body['code_snippet'] : ''; if (empty($fix_type) || empty($title)) { return new WP_Error( 'citami_bad_payload', 'fix_type e title sono obbligatori.', array('status' => 400) ); } // Always queue (audit trail) — irrespective of review-only mode. $queue_id = citami_queue_fix($fix_type, $title, $description, $code_snippet, $fix_id); // Round 11B: server-side auto-apply (header X-Citami-Auto-Apply: 1 // OR install-time CITAMI_PRELOADED_AUTO_APPLY_MODE='enabled') // overrides review-only for this single call. The customer opted // in via /dashboard/settings/auto-apply; honoring that here is // the whole point of the auto-push cron. citami_is_review_only() // still wins when neither signal is present, so v2.1.x defaults // (and customers who never enabled auto-apply) keep their queue // workflow. // 2.65 — $queue_only: the sync pull got a fix the platform has not // released for auto-apply → it waits for approval, review-only or not. if (($queue_only || citami_is_review_only()) && !$force_apply) { do_action('citami_fix_applied', $fix_type, $title); // 2.42 — the activity log return rest_ensure_response(array( 'success' => true, 'message' => 'Fix accodato in review (auto-apply disattivato).', 'review_only' => true, )); } // 2.59 — applied here, not by a person: the queue row says so (it used to // stay "pending", with Approve/Reject on a fix already live and no Revoke). $res = citami_apply_fix_now($fix_type, $title, $code_snippet, $fix_id); if ($queue_id !== '') { $data = is_wp_error($res) ? array('success' => false, 'message' => $res->get_error_message()) : (array) $res->get_data(); $manual = !in_array($fix_type, array('llms_txt', 'robots_txt', 'schema_injection', 'schema_markup', 'meta_tags', 'faq_page'), true); citami_update_fix_in_queue($queue_id, array( 'status' => $manual ? 'pending' : (!empty($data['success']) ? 'applied' : 'failed'), 'actioned_at' => $manual ? null : gmdate('c'), 'actioned_by' => $manual ? null : 'auto', 'action_message' => isset($data['message']) ? (string) $data['message'] : null, )); } return $res; } /** The apply step of citami_apply_fix_payload(), once the fix is cleared to go live. */ function citami_apply_fix_now($fix_type, $title, $code_snippet, $fix_id) { switch ($fix_type) { case 'llms_txt': $ok = citami_apply_llms_txt($code_snippet); if ($ok) { update_option(CITAMI_LLMS_FIX_OPT, array('fix_id' => $fix_id, 'title' => $title), false); } return rest_ensure_response(array( 'success' => $ok, 'message' => $ok ? 'llms.txt scritto in radice.' : 'Impossibile scrivere llms.txt (permessi file).', )); case 'robots_txt': citami_append_robots_rule($code_snippet, $title, $fix_id); return rest_ensure_response(array( 'success' => true, 'message' => 'Regole robots.txt aggiunte.', )); case 'schema_injection': case 'schema_markup': $safe_json = citami_validate_jsonld($code_snippet); if ($safe_json === false) { citami_debug_log('[citami] schema_injection rejected: invalid JSON-LD or missing @context'); return new WP_Error( 'citami_invalid_jsonld', 'JSON-LD non valido o privo di @context schema.org.', array('status' => 400) ); } // 2.65 — never live while incomplete (placeholders, missing // required properties): same rule as the platform. $missing = citami_schema_missing($safe_json); if (!empty($missing)) { return new WP_Error( 'citami_incomplete_schema', citami_t('Schema incompleto, non applicato. Mancano: ', 'Incomplete schema, not applied. Missing: ') . implode(', ', array_slice($missing, 0, 6)), array('status' => 422) ); } // v2.4.0 — @graph viene spacchettato in blocchi separati, // ognuno col proprio target (home/about/faq) derivato dal // @type. Un singolo fix consegna schema multi-pagina. // v2.11.0 — logica estratta in citami_store_schema_payload // e riusata dal percorso Approva della coda review. $stored = citami_store_schema_payload($title, $safe_json); citami_tag_head_blocks(CITAMI_SCHEMA_OPT, $title, $fix_id); return rest_ensure_response(array( 'success' => $stored > 0, 'message' => $stored > 1 ? sprintf('Schema JSON-LD: %d blocchi instradati per pagina.', $stored) : ($stored === 1 ? 'Schema JSON-LD aggiunto a .' : 'JSON-LD @graph vuoto o non valido.'), )); case 'meta_tags': $allowed = citami_meta_kses_allowlist(); $safe_html = wp_kses((string) $code_snippet, $allowed); if (trim($safe_html) === '') { citami_debug_log('[citami] meta_tags rejected: empty after wp_kses allow-list filter'); return new WP_Error( 'citami_invalid_meta', 'Meta tag non validi (filtrati interamente dalla allow-list).', array('status' => 400) ); } // v2.11.1 — replace-at-fix-level anche per i meta tag: // riapplicare lo stesso fix sostituisce il blocco invece // di accumularne copie. citami_purge_head_blocks_by_title(CITAMI_META_OPT, $title); citami_append_head_block(CITAMI_META_OPT, $title, $safe_html); citami_tag_head_blocks(CITAMI_META_OPT, $title, $fix_id); return rest_ensure_response(array( 'success' => true, 'message' => 'Meta tag aggiunti a .', )); case 'faq_page': // v2.12.0 — one-click: pagina FAQ reale (contenuto visibile // + schema), vedi citami_apply_faq_page. $faq = citami_apply_faq_page($title, $code_snippet, $fix_id); citami_tag_head_blocks(CITAMI_SCHEMA_OPT, $title, $fix_id); return rest_ensure_response(array( 'success' => $faq['ok'], 'message' => $faq['message'], )); default: // bio_author, content_restructure, ecc. — richiedono // interazione manuale. Restano nella coda review. return rest_ensure_response(array( 'success' => true, 'message' => 'Fix accodato per revisione manuale.', )); } } function citami_queue_fix($fix_type, $title, $description, $code_snippet, $fix_id = '') { $queue = get_option(CITAMI_FIX_QUEUE_OPT, array()); if (!is_array($queue)) { $queue = array(); } // v2.1.0 added explicit id + status. uniqid is best-effort unique // (collision is harmless — would just re-target the older row on // an action click). Older queue entries from v2.0.x lack these // fields; the rendering layer treats missing status as 'pending'. array_unshift($queue, array( 'id' => uniqid('fix_', true), 'fix_id' => (string) $fix_id, 'status' => 'pending', 'fix_type' => $fix_type, 'title' => $title, 'description' => (string) $description, 'code_snippet' => (string) $code_snippet, 'received_at' => gmdate('c'), 'actioned_at' => null, 'actioned_by' => null, 'action_message' => null, )); // Cap at 50 most recent to keep options table lean. $queue = array_slice($queue, 0, 50); update_option(CITAMI_FIX_QUEUE_OPT, $queue, false); return (string) $queue[0]['id']; } /** * Find a fix in the queue by id and update specific fields. Used by the * admin Approve/Reject handler. Returns the previous fix array or null * if not found. */ function citami_update_fix_in_queue($id, array $patch) { $queue = get_option(CITAMI_FIX_QUEUE_OPT, array()); if (!is_array($queue)) { return null; } foreach ($queue as $idx => $fix) { if (!is_array($fix) || !isset($fix['id']) || $fix['id'] !== $id) { continue; } $previous = $fix; $queue[$idx] = array_merge($fix, $patch); update_option(CITAMI_FIX_QUEUE_OPT, $queue, false); return $previous; } return null; } /** * v2.6.0 — LLMS-CONTENT. Pre-v2.6 il file llms.txt conteneva solo lo * snippet generato dall'audit (descrizione business): nessun riferimento * alle pagine/articoli/prodotti REALI del sito, che sono esattamente * cio' che un motore AI usa per capire e citare. Ora il plugin appende * una sezione auto-generata dai contenuti WordPress (pagine top-level, * articoli recenti, categorie prodotto WooCommerce) e la rinfresca con * un cron giornaliero, preservando la parte manuale sopra il marker. */ function citami_llms_auto_marker() { // 2.56.1 — neutral wording: the old marker printed Italian and the // brand name in every public llms.txt, CiteProof sites included. return ''; } /** Where the auto section starts, new or pre-2.56.1 marker; false if none. */ function citami_llms_marker_pos($text) { $found = array(); foreach (array(citami_llms_auto_marker(), '') as $m) { $p = strpos((string) $text, $m); if ($p !== false) $found[] = $p; } return $found ? min($found) : false; } function citami_build_llms_auto_section() { $is_it = (stripos((string) get_locale(), 'it') === 0); $lines = array(); $lines[] = citami_llms_auto_marker(); $lines[] = ''; // 2.42 — pages pinned from the Overview ("Aggiungi a llms.txt"): the // important pages no AI has read yet go first, so the engines find them. $pinned = array_filter(array_map('intval', (array) get_option(CITAMI_LLMS_PINNED_OPT, array()))); if (!empty($pinned)) { $lines[] = citami_t('## Pagine importanti', '## Important pages'); foreach ($pinned as $pid) { $pp = get_post($pid); if (!$pp || $pp->post_status !== 'publish') continue; // 2.52 — only public content: an Elementor kit or a template // pinned by mistake used to print as "Default Kit" up top. if (!is_post_type_viewable($pp->post_type)) continue; $title = wp_strip_all_tags((string) $pp->post_title); if ($title === '') continue; $lines[] = '- [' . $title . '](' . get_permalink($pp) . ')'; } $lines[] = ''; } // Top-level published pages (menu order = editorial priority). $pages = get_pages(array( 'sort_column' => 'menu_order', 'parent' => 0, 'number' => 15, )); // 2.70.2 — the WooCommerce utility pages (cart, checkout, account) are // not content an AI engine should read: a store's llms.txt opened with // "Cart / Checkout / My account" at the top, before the real pages. $skip = array(); if (function_exists('wc_get_page_id')) { foreach (array('cart', 'checkout', 'myaccount') as $wc_page) { $wc_id = (int) wc_get_page_id($wc_page); if ($wc_id > 0) $skip[$wc_id] = true; } } if (is_array($pages) && !empty($pages)) { $lines[] = citami_t('## Pagine principali', '## Main pages'); foreach ($pages as $pg) { if (isset($skip[(int) $pg->ID])) continue; $title = wp_strip_all_tags((string) $pg->post_title); if ($title === '') continue; $lines[] = '- [' . $title . '](' . get_permalink($pg) . ')'; } $lines[] = ''; } // Recent posts — freshness signal for the AI engines. $posts = get_posts(array( 'numberposts' => 10, 'post_status' => 'publish', )); if (is_array($posts) && !empty($posts)) { $lines[] = citami_t('## Articoli recenti', '## Recent articles'); foreach ($posts as $po) { $title = wp_strip_all_tags((string) $po->post_title); if ($title === '') continue; $lines[] = '- [' . $title . '](' . get_permalink($po) . ')'; } $lines[] = ''; } // WooCommerce product categories (when the shop plugin is active). if (class_exists('WooCommerce')) { $terms = get_terms(array( 'taxonomy' => 'product_cat', 'hide_empty' => true, 'number' => 12, )); if (is_array($terms) && !empty($terms) && !is_wp_error($terms)) { $lines[] = citami_t('## Categorie prodotto', '## Product categories'); foreach ($terms as $term) { $link = get_term_link($term); if (is_wp_error($link)) continue; $lines[] = '- [' . wp_strip_all_tags((string) $term->name) . '](' . $link . ')'; } $lines[] = ''; } } // WP17 — modules add sections (e.g. the /ai-info page). foreach ((array) apply_filters('citami_llms_extra_sections', array()) as $section) { if (is_string($section) && $section !== '') { $lines[] = ''; $lines[] = $section; } } return implode("\n", $lines); } function citami_apply_llms_txt($content) { $path = ABSPATH . 'llms.txt'; $manual = rtrim((string) $content); // Se lo snippet contiene gia' un nostro marker (riapplicazione), // tieni solo la parte manuale sopra di esso. $marker_pos = citami_llms_marker_pos($manual); if ($marker_pos !== false) { $manual = rtrim(substr($manual, 0, $marker_pos)); } $full = $manual . "\n\n" . citami_build_llms_auto_section() . "\n"; $result = @file_put_contents($path, $full); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected,WordPress.PHP.NoSilencedErrors.Discouraged -- llms.txt is only found at the site root; a read-only root is reported, not fatal return $result !== false; } /** * Cron giornaliero: rigenera SOLO la sezione auto del llms.txt (nuove * pagine/articoli compaiono da soli), preservando la parte manuale. * No-op se il file non esiste o non contiene il nostro marker (file * scritto a mano dal cliente: mai toccarlo). */ function citami_refresh_llms_txt_handler() { $path = ABSPATH . 'llms.txt'; if (!file_exists($path)) return; $current = @file_get_contents($path); if (!is_string($current)) return; $marker_pos = citami_llms_marker_pos($current); if ($marker_pos === false) return; $manual = rtrim(substr($current, 0, $marker_pos)); $full = $manual . "\n\n" . citami_build_llms_auto_section() . "\n"; @file_put_contents($path, $full); // phpcs:ignore PluginCheck.CodeAnalysis.WriteFile.ABSPATHDetected,WordPress.PHP.NoSilencedErrors.Discouraged -- same file, refreshed in place } add_action('citami_refresh_llms_txt', 'citami_refresh_llms_txt_handler'); function citami_append_robots_rule($content, $title = '', $fix_id = '') { // v2.13.0 — fix-keyed segments; the flat string is derived. Re-applying // the same fix replaces its segment instead of appending a copy. $segments = citami_robots_segments(); $kept = array(); foreach ($segments as $s) { $same = ($fix_id !== '' && isset($s['fix_id']) && $s['fix_id'] === $fix_id) || ($fix_id === '' && $title !== '' && isset($s['title']) && $s['title'] === $title); if (!$same) { $kept[] = $s; } } $kept[] = array('fix_id' => (string) $fix_id, 'title' => (string) $title, 'content' => trim((string) $content)); citami_rebuild_robots_rules($kept); } add_filter('robots_txt', function ($output) { $extra = get_option(CITAMI_ROBOTS_OPT, ''); if (is_string($extra) && $extra !== '') { $output .= "\n# " . citami_brand_name() . "\n" . $extra . "\n"; } return $output; }, 10, 1); /** * v2.11.1 — REPLACE-AT-FIX-LEVEL. Rimuove TUTTI i blocchi memorizzati * con lo stesso titolo fix, qualunque sia il target. Va chiamata PRIMA * di riappendere i blocchi di una (ri)applicazione del fix. * * Perché: la dedup v2.11.0 era title+target+chiave-nodo (@id/@type). * Se la riapplicazione cambiava la chiave del nodo — LocalBusiness → * Dentist dopo i tipi settoriali S1, nodo org che guadagna un @id * quando S2 aggiunge una Person, Person rimossa in seguito — il * blocco vecchio NON veniva mai eliminato: JSON-LD identità stantio * duplicato in pagina, e sotto Yoast il merge pescava il PRIMO match * identità = il blocco STANTIO. Il purge per titolo elimina anche i * blocchi legacy senza target ('') delle approvazioni in coda * pre-2.11. Ritorna il numero di blocchi rimossi. */ function citami_purge_head_blocks_by_title($option, $title) { $blocks = get_option($option, array()); if (!is_array($blocks)) { return 0; } $removed = 0; foreach ($blocks as $idx => $b) { if (is_array($b) && isset($b['title']) && $b['title'] === $title) { unset($blocks[$idx]); $removed++; } } if ($removed > 0) { update_option($option, array_values($blocks), false); } return $removed; } /** * Append puro, NESSUNA dedup qui. La sostituzione avviene a livello di * fix (citami_purge_head_blocks_by_title prima del batch di append): * dentro uno stesso batch i nodi NON si deduplicano tra loro — la dedup * intra-batch era il bug del drop silenzioso (un @graph Organization + * Person + Menu teneva solo l'ultimo nodo; e due nodi con stesso @type * senza @id collidevano). Purge + re-append dello stesso contenuto = * idempotente, nessuna crescita. */ function citami_append_head_block($option, $title, $content, $target = '') { $blocks = get_option($option, array()); if (!is_array($blocks)) { $blocks = array(); } $entry = array( 'title' => $title, 'content' => (string) $content, ); if ($target !== '') { $entry['target'] = (string) $target; } $blocks[] = $entry; update_option($option, $blocks, false); }