'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);
}