/s', ' ', (string) $html);
$html = preg_replace('#<(script|style)\b[^>]*>.*?\1>#is', ' ', $html);
$html = preg_replace('#?(p|h[1-6]|li|ul|ol|div|br|tr|td|th|table|blockquote|figure|section|article)\b[^>]*>#i', ' ', $html);
$text = html_entity_decode(wp_strip_all_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = mb_strtolower(preg_replace('/\s+/u', ' ', $text));
return ' ' . trim($text) . ' ';
}
/**
* 2.63 — only the text a link can be written into: headings, existing
* links, code and buttons removed, exactly what citami_links_wrap_first()
* skips. Suggestions are counted on this, so the plugin never offers a
* phrase that sits only in a heading (the insert would then refuse it).
*/
function citami_links_linkable_plain($html) {
$html = preg_replace('//s', ' ', (string) $html);
$html = preg_replace('#<(h[1-6]|a|code|pre|button|script|style|textarea)\b[^>]*>.*?\1\s*>#is', ' ', $html);
$html = preg_replace('/\[[^\]]+\]/', ' ', $html);
return citami_links_plain($html);
}
/** Why a phrase cannot be linked in this HTML: 'heading', 'link', 'missing'. */
function citami_links_unlinkable_reason($html, $phrase) {
$phrase = mb_strtolower(trim((string) $phrase));
if (citami_links_count_phrase(citami_links_plain($html), $phrase) === 0) {
return 'missing';
}
if (preg_match_all('#<(h[1-6])\b[^>]*>(.*?)\1\s*>#is', (string) $html, $m)) {
foreach ($m[2] as $h) {
if (citami_links_count_phrase(citami_links_plain($h), $phrase) > 0) {
return 'heading';
}
}
}
return 'link';
}
/** Title without the site name / separator tail, lower-cased. */
function citami_links_clean_title($title) {
$t = mb_strtolower(trim((string) $title));
$t = preg_replace('/\s*[\|\-–—·»:]\s*' . preg_quote(mb_strtolower(get_bloginfo('name')), '/') . '\s*$/u', '', $t);
$t = trim(preg_replace('/[^\p{L}\p{N}\'’\s-]/u', ' ', $t));
return trim(preg_replace('/\s+/u', ' ', $t));
}
/** Candidate anchor phrases for a target: focus keyword, cleaned title, title minus leading stopwords. */
function citami_links_phrases(array $entry) {
$phrases = array();
$kw = mb_strtolower(trim((string) $entry['keyword']));
if ($kw !== '' && mb_strlen($kw) >= 4) {
$phrases[] = $kw;
}
$title = citami_links_clean_title($entry['title']);
$words = $title === '' ? array() : explode(' ', $title);
if (count($words) >= 2) {
$phrases[] = $title;
// "Come scegliere un dentista" → "scegliere un dentista": the question
// word rarely appears mid-sentence, the rest does.
$stop = citami_links_stopwords();
$trimmed = $words;
while (!empty($trimmed) && in_array($trimmed[0], $stop, true)) {
array_shift($trimmed);
}
if (count($trimmed) >= 2 && count($trimmed) < count($words)) {
$phrases[] = implode(' ', $trimmed);
}
// "Come funziona la sedazione cosciente" → "sedazione cosciente": the
// tail after the last stopword is what other pages actually write.
$last = -1;
foreach ($words as $i => $w) {
if (in_array($w, $stop, true)) {
$last = $i;
}
}
$tail = $last >= 0 ? array_slice($words, $last + 1) : array();
if (count($tail) >= 2 && count($tail) < count($words)) {
$phrases[] = implode(' ', $tail);
}
}
foreach ((array) $entry['terms'] as $term) {
$term = mb_strtolower(trim((string) $term));
if ($term !== '' && mb_strlen($term) >= 6 && strpos($term, ' ') !== false) {
$phrases[] = $term;
}
}
return array_values(array_unique($phrases));
}
/** Occurrences of a phrase in plain text (whole words, case-insensitive). */
function citami_links_count_phrase($plain, $phrase) {
$p = preg_quote($phrase, '/');
$p = strtr($p, array('\\ ' => '\s+', "'" => "['’]", '’' => "['’]")); // simultaneous: no double replacement
return preg_match_all('/(?]*\bhref\s*=\s*["\']([^"\']+)["\']/i', (string) $html, $m)) {
return $ids;
}
$home_host = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
foreach ($m[1] as $href) {
$href = trim(html_entity_decode($href));
if ($href === '' || $href[0] === '#' || preg_match('/^(mailto|tel|javascript):/i', $href)) {
continue;
}
if (preg_match('#^https?://#i', $href)) {
$host = strtolower((string) wp_parse_url($href, PHP_URL_HOST));
if (preg_replace('/^www\./', '', $host) !== preg_replace('/^www\./', '', $home_host)) {
continue;
}
} elseif ($href[0] === '/') {
$href = home_url($href);
} else {
continue;
}
$id = (int) url_to_postid(preg_replace('/[#?].*$/', '', $href));
if ($id > 0) {
$ids[$id] = true;
}
}
return array_keys($ids);
}
/**
* LINKS-1 — the internal links of a page with their anchor text:
* target post id => anchor (the first link to each target), plus how many
* internal links the content holds in total. Same href resolution as
* citami_links_outbound_ids(); the anchor is the link's visible text.
*/
function citami_links_outbound_anchors($html) {
$out = array('anchors' => array(), 'count' => 0);
if (!preg_match_all('/]*\bhref\s*=\s*["\']([^"\']+)["\'][^>]*>(.*?)<\/a\s*>/is', (string) $html, $m, PREG_SET_ORDER)) {
return $out;
}
foreach ($m as $link) {
$ids = citami_links_outbound_ids('x');
if (empty($ids)) {
continue;
}
$out['count']++;
$id = (int) $ids[0];
if (!isset($out['anchors'][$id])) {
$text = html_entity_decode(wp_strip_all_tags((string) $link[2]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$out['anchors'][$id] = mb_strtolower(trim(preg_replace('/\s+/u', ' ', $text)));
}
}
return $out;
}
/* ---------------------------------------------------------------- */
/* Index */
/* ---------------------------------------------------------------- */
function citami_links_entry_for_post($post) {
$terms = array();
if (function_exists('get_the_terms')) {
foreach (array('category', 'post_tag') as $tax) {
$list = get_the_terms($post->ID, $tax);
if (is_array($list)) {
foreach ($list as $t) {
if (is_object($t) && isset($t->name) && $t->name !== 'Uncategorized' && $t->name !== 'Senza categoria') {
$terms[] = (string) $t->name;
}
}
}
}
}
$html = citami_post_content_html($post);
$anchored = citami_links_outbound_anchors($html);
return array(
'id' => (int) $post->ID,
'type' => (string) $post->post_type,
'title' => (string) $post->post_title,
'url' => get_permalink($post),
'keyword' => (string) get_post_meta($post->ID, CITAMI_META_FOCUS_KEYWORD, true),
'terms' => array_values(array_unique($terms)),
'outbound' => citami_links_outbound_ids($html),
// LINKS-1 — anchor text per linked page and the total of internal links in the content.
'anchors' => $anchored['anchors'],
'links_n' => (int) $anchored['count'],
'noindex' => (string) get_post_meta($post->ID, CITAMI_META_ROBOTS_INDEX, true) === 'noindex',
);
}
/** Rebuilds the whole index (published, public types). */
function citami_links_index_build() {
$posts = get_posts(array(
'post_type' => citami_seo_post_types(),
'post_status' => 'publish',
'numberposts' => CITAMI_LINKS_MAX_POSTS,
'orderby' => 'ID',
'order' => 'DESC',
));
$entries = array();
foreach ((array) $posts as $p) {
if (is_object($p)) {
$entries[(int) $p->ID] = citami_links_entry_for_post($p);
}
}
$index = array('built' => time(), 'version' => citami_links_next_version(), 'entries' => $entries);
update_option(CITAMI_LINKS_INDEX_OPT, $index, false);
return $index;
}
/** Monotonic index version: every build/touch bumps it, per-page caches key on it. */
function citami_links_next_version() {
$current = get_option(CITAMI_LINKS_INDEX_OPT, null);
$v = is_array($current) && isset($current['version']) ? (int) $current['version'] : 0;
return max($v + 1, time());
}
function citami_links_index($build_if_missing = true) {
$index = get_option(CITAMI_LINKS_INDEX_OPT, null);
if ((!is_array($index) || !isset($index['entries'])) && $build_if_missing) {
$index = citami_links_index_build();
}
if (is_array($index) && isset($index['entries'])) {
if (!isset($index['version'])) {
$index['version'] = (int) $index['built'];
}
return $index;
}
return array('built' => 0, 'version' => 0, 'entries' => array());
}
/** Updates one entry (on save) without a full rebuild. */
function citami_links_index_touch($post_id) {
$index = citami_links_index(false);
if (empty($index['entries']) && $index['built'] === 0) {
return; // never built: the first reader builds it whole
}
$post = get_post($post_id);
if ($post && $post->post_status === 'publish' && in_array($post->post_type, citami_seo_post_types(), true)) {
$index['entries'][(int) $post_id] = citami_links_entry_for_post($post);
} else {
unset($index['entries'][(int) $post_id]);
}
$index['version'] = citami_links_next_version();
update_option(CITAMI_LINKS_INDEX_OPT, $index, false);
}
add_action('save_post', function ($post_id) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (function_exists('wp_is_post_revision') && wp_is_post_revision($post_id)) {
return;
}
citami_links_index_touch($post_id);
}, 30, 1);
add_action('before_delete_post', function ($post_id) { citami_links_index_touch($post_id); });
add_action('wp_trash_post', function ($post_id) {
$index = citami_links_index(false);
unset($index['entries'][(int) $post_id]);
$index['version'] = citami_links_next_version();
update_option(CITAMI_LINKS_INDEX_OPT, $index, false);
});
// Daily full rebuild (menus, term renames, anything the hooks miss).
add_action(CITAMI_LINKS_HOOK, 'citami_links_index_build');
add_action('init', function () {
if (!wp_next_scheduled(CITAMI_LINKS_HOOK)) {
wp_schedule_event(time() + 3 * HOUR_IN_SECONDS, 'daily', CITAMI_LINKS_HOOK);
}
}, 20);
/** Inbound internal link counts, keyed by target id. */
function citami_links_inbound_counts(array $index) {
$counts = array();
foreach ($index['entries'] as $id => $e) {
$counts[$id] = isset($counts[$id]) ? $counts[$id] : 0;
}
foreach ($index['entries'] as $id => $e) {
foreach ((array) $e['outbound'] as $target) {
if ((int) $target !== (int) $id && isset($index['entries'][$target])) {
$counts[$target] = (isset($counts[$target]) ? $counts[$target] : 0) + 1;
}
}
}
return $counts;
}
/* ---------------------------------------------------------------- */
/* Suggestions */
/* ---------------------------------------------------------------- */
/**
* Outbound suggestions for a piece of content: targets whose anchor phrase
* appears in the text and that are not linked yet. Each: id, title, url,
* anchor, count, score.
*/
function citami_links_suggest($post_id, $content, $limit = CITAMI_LINKS_MAX_SUGGESTIONS) {
$index = citami_links_index();
$plain = citami_links_linkable_plain($content);
if (trim($plain) === '' || empty($index['entries'])) {
return array();
}
$linked = array_flip(citami_links_outbound_ids($content));
$inbound = citami_links_inbound_counts($index);
$out = array();
foreach ($index['entries'] as $id => $e) {
if ((int) $id === (int) $post_id || isset($linked[$id]) || !empty($e['noindex'])) {
continue;
}
$best = null;
foreach (citami_links_phrases($e) as $phrase) {
$n = citami_links_count_phrase($plain, $phrase);
if ($n === 0) {
continue;
}
$words = substr_count($phrase, ' ') + 1;
$score = $words * 10 + min($n, 3) * 2 + ($e['type'] === 'page' ? 3 : 0) + (empty($inbound[$id]) ? 5 : 0) + ($phrase === mb_strtolower(trim((string) $e['keyword'])) ? 4 : 0);
if ($best === null || $score > $best['score']) {
$best = array('id' => (int) $id, 'title' => $e['title'], 'url' => $e['url'], 'anchor' => $phrase, 'count' => $n, 'score' => $score, 'inbound' => isset($inbound[$id]) ? (int) $inbound[$id] : 0);
}
}
if ($best !== null) {
$out[] = $best;
}
}
usort($out, function ($a, $b) { return $b['score'] <=> $a['score'] ?: strcmp($a['title'], $b['title']); });
return array_slice($out, 0, $limit);
}
/** Pages that mention this post's phrases but do not link to it. */
/**
* Plain text of every indexed post, loaded ONCE per request with a single
* query (2000 get_post() calls were the cost of opening the editor on a
* big site). Only the ids the index knows; empty content → ''.
*/
function citami_links_all_plain($reset = false) {
static $cache = null;
if ($reset) {
$cache = null;
return array();
}
if ($cache !== null) {
return $cache;
}
$cache = array();
$posts = get_posts(array(
'post_type' => citami_seo_post_types(),
'post_status' => 'publish',
'numberposts' => CITAMI_LINKS_MAX_POSTS,
'orderby' => 'ID',
'order' => 'DESC',
));
foreach ((array) $posts as $p) {
if (is_object($p)) {
$cache[(int) $p->ID] = citami_links_linkable_plain(citami_post_content_html($p));
}
}
return $cache;
}
function citami_links_inbound_cache_key($post_id) {
return 'citami_links_in_' . (int) $post_id;
}
/**
* Pages that mention this post's phrases but do not link to it. Cached per
* page in a transient keyed on the index version: a save anywhere bumps
* the version, so no stale answer survives a content change and no
* request ever scans the site twice for the same page.
*/
function citami_links_inbound_candidates($post_id, $limit = CITAMI_LINKS_MAX_SUGGESTIONS) {
$index = citami_links_index();
if (!isset($index['entries'][(int) $post_id])) {
return array();
}
$key = citami_links_inbound_cache_key($post_id);
$cached = get_transient($key);
if (is_array($cached) && isset($cached['version'], $cached['items']) && (int) $cached['version'] === (int) $index['version']) {
return array_slice((array) $cached['items'], 0, $limit);
}
$target = $index['entries'][(int) $post_id];
$phrases = citami_links_phrases($target);
$out = array();
if (!empty($phrases)) {
$plain_by_id = citami_links_all_plain();
foreach ($index['entries'] as $id => $e) {
if ((int) $id === (int) $post_id || in_array((int) $post_id, (array) $e['outbound'], true) || !isset($plain_by_id[(int) $id])) {
continue;
}
$plain = $plain_by_id[(int) $id];
foreach ($phrases as $phrase) {
$n = citami_links_count_phrase($plain, $phrase);
if ($n > 0) {
$out[] = array('id' => (int) $id, 'title' => $e['title'], 'url' => $e['url'], 'anchor' => $phrase, 'count' => $n, 'score' => (substr_count($phrase, ' ') + 1) * 10 + min($n, 3) * 2);
break;
}
}
}
usort($out, function ($a, $b) { return $b['score'] <=> $a['score'] ?: strcmp($a['title'], $b['title']); });
$out = array_slice($out, 0, CITAMI_LINKS_MAX_SUGGESTIONS);
}
set_transient($key, array('version' => (int) $index['version'], 'items' => $out), DAY_IN_SECONDS);
return array_slice($out, 0, $limit);
}
/** Orphans: published, indexable, no internal inbound link. */
function citami_links_orphans($limit = 50) {
$index = citami_links_index();
$inbound = citami_links_inbound_counts($index);
$out = array();
foreach ($index['entries'] as $id => $e) {
if (empty($inbound[$id]) && empty($e['noindex'])) {
$out[] = $e;
}
}
usort($out, function ($a, $b) { return strcmp($a['title'], $b['title']); });
return array_slice($out, 0, $limit);
}
/* ---------------------------------------------------------------- */
/* REST (live, editors) + editor panel */
/* ---------------------------------------------------------------- */
function citami_rest_link_suggestions(WP_REST_Request $request) {
$body = $request->get_json_params();
$body = is_array($body) ? $body : array();
$post_id = isset($body['post_id']) ? (int) $body['post_id'] : 0;
$post = $post_id > 0 ? get_post($post_id) : null;
if (!$post) {
return new WP_Error('citami_links_post', 'post_id required', array('status' => 400));
}
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error('citami_links_forbidden', 'forbidden', array('status' => 403));
}
$content = isset($body['content']) && is_string($body['content']) ? $body['content'] : (string) (isset($post->post_content) ? $post->post_content : '');
return rest_ensure_response(array(
'outbound' => citami_links_suggest($post_id, $content),
'inbound' => citami_links_inbound_candidates($post_id),
));
}
add_action('rest_api_init', function () {
register_rest_route('citami/v1', '/link-suggestions', array(
'methods' => 'POST',
'callback' => 'citami_rest_link_suggestions',
'permission_callback' => function () { return current_user_can('edit_posts'); },
));
});
/** 2.49 — the editor's own insert link: GET, nonce keyed by the post, back to the editor. */
function citami_links_editor_insert_base($post_id) {
return wp_nonce_url(admin_url('admin-post.php?action=citami_links_action&do=insert&back=editor&post_id=' . (int) $post_id), 'citami_links_editor_' . (int) $post_id);
}
function citami_links_editor_insert_url($post_id, $target_id, $anchor) {
return citami_links_editor_insert_base($post_id) . '&target=' . (int) $target_id . '&pick=' . rawurlencode((int) $post_id . '::' . (string) $anchor);
}
function citami_links_render_list(array $rows, $kind, $post_id = 0, $editable = false) {
if (empty($rows)) {
return '
' . esc_html($kind === 'outbound' ? citami_t('Nessuna frase del testo corrisponde a un\'altra pagina del sito.', 'No phrase in the text matches another page of the site.') : citami_t('Nessuna pagina menziona questo argomento senza linkarlo.', 'No page mentions this topic without linking it.')) . '
';
}
function citami_links_render_panel($post) {
$rest = function_exists('rest_url') ? rest_url('citami/v1/link-suggestions') : '';
$nonce = wp_create_nonce('wp_rest');
$editable = citami_links_post_editable($post->ID);
$out = citami_links_suggest($post->ID, citami_post_content_html($post));
$in = citami_links_inbound_candidates($post->ID);
?>
citami_t('Nessuna frase del testo corrisponde a un\'altra pagina del sito.', 'No phrase in the text matches another page of the site.'),
'noInbound' => citami_t('Nessuna pagina menziona questo argomento senza linkarlo.', 'No page mentions this topic without linking it.'),
'insert' => citami_t('Inserisci il link', 'Insert the link'),
'nobody' => citami_t('nessuno la linka ancora', 'nobody links it yet'),
'mentions' => citami_t('menziona', 'mentions'),
'network' => citami_t('Errore di rete', 'Network error'),
);
?>