Sync: guardar en el historial el trabajo de las ultimas semanas que solo vivia en el disco

Este repo local tenia origin apuntando al Gitea local (localhost:3000), que Rafa
declaro archivado el 2026-06-28 (commit 962f33a, desarrollo movido a
gitea.feadulta.com). Ese memo nunca llego a este checkout: main quedo congelado
y todo el trabajo real de las ultimas 3+ semanas se fue commiteando solo en la
rama fix/multiidioma-portada-132 (ya fusionada a main sin perdida, commit
2504666), mientras que ademas se acumulaban 78 cambios sin commitear en el
working tree que nunca llegaron a NINGUN historial de git.

Este commit consolida esos cambios sueltos: TTS multi-voz (tts_*.py), scripts
de traduccion (translate_haiku.py, pretranslate_en_haiku.py, sync_translations_to_prod.py),
mu-plugins nuevos desplegados a prod (fea-beta-feedback, fea-cloudflare-realip,
fea-legacy-redirect, fea-gsc-verification, fea-support-campaign, fea-ui, etc.),
scripts de mantenimiento de enlaces/cartas, capturas E2E (tools/e2e/shot_*.cjs)
y documentacion de sesiones recientes.

Excluido deliberadamente (no es codigo versionable): tts-voices/ (3.3GB de
muestras de audio para clonacion de voz, anadido a .gitignore), logs/ (logs de
ejecucion, anadido a .gitignore), y 2 ficheros vacios accidentales + 2 copias
duplicadas sueltas en la raiz que ya existen en su ubicacion correcta.
This commit is contained in:
2026-07-15 20:03:21 -04:00
parent 250466696b
commit 69e849d38e
72 changed files with 5413 additions and 220 deletions
+42 -21
View File
@@ -2,7 +2,7 @@
/**
* Plugin Name: Fe Adulta — Carta de la Semana
* Description: Redirige las URLs de carta al archivo de categoría correspondiente.
* Version: 1.7
* Version: 1.8
*/
// Redirigir las páginas custom a las categorías
@@ -17,38 +17,59 @@ add_action("template_redirect", function() {
}
});
// Si la categoría tiene un solo artículo, ir directamente a él en el idioma actual
// (stop-redirects.php desactiva redirect_canonical que haría esto automáticamente)
// Las categorías de carta actual/anterior siempre llevan al post traducido que
// corresponde a la categoría española canónica. No dependemos del count ni de
// las relaciones traducidas, que pueden quedar desfasadas durante una importación.
add_action("template_redirect", function() {
if (!is_category()) return;
$cat = get_queried_object();
if (!$cat || $cat->count != 1) return;
$posts = get_posts([
'cat' => $cat->term_id,
'numberposts' => 1,
'post_status' => 'publish',
]);
if (!$posts) return;
$post_id = $posts[0]->ID;
// Buscar la traducción al idioma actual
if (!$cat || empty($cat->term_id)) return;
$source_cat_id = (int) $cat->term_id;
if (function_exists('pll_get_term')) {
$spanish_cat_id = (int) pll_get_term($source_cat_id, 'es');
if ($spanish_cat_id) $source_cat_id = $spanish_cat_id;
}
if (!in_array($source_cat_id, [6, 22], true)) return;
global $wpdb;
$source_post_id = (int) $wpdb->get_var($wpdb->prepare(
"SELECT p.ID
FROM {$wpdb->posts} p
INNER JOIN {$wpdb->term_relationships} tr ON tr.object_id = p.ID
INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
WHERE tt.taxonomy = 'category' AND tt.term_id = %d
AND p.post_type = 'post' AND p.post_status = 'publish'
ORDER BY p.post_date DESC, p.ID DESC
LIMIT 1",
$source_cat_id
));
if (!$source_post_id) return;
$post_id = $source_post_id;
if (function_exists('pll_current_language') && function_exists('pll_get_post')) {
$lang = pll_current_language();
if ($lang) {
$translated = pll_get_post($post_id, $lang);
if ($translated) $post_id = $translated;
}
$translated = $lang ? (int) pll_get_post($source_post_id, $lang) : 0;
if ($translated) $post_id = $translated;
}
wp_redirect(get_permalink($post_id), 302);
$url = get_permalink($post_id);
if (!$url) return;
wp_safe_redirect($url, 302);
exit;
}, 9);
// Mostrar 50 artículos por página en los archivos de cartas
add_action("pre_get_posts", function($query) {
if (!$query->is_main_query() || is_admin()) return;
if ($query->is_category(["cartasemana", "cartas-de-otras-semanas", "carta-semana-pasada",
"cartas-de-otras-semanas-en", "cartas-de-otras-semanas-fr",
"cartas-de-otras-semanas-it", "cartas-de-otras-semanas-pt",
"cartasemana-en", "cartasemana-fr", "cartasemana-it", "cartasemana-pt"])) {
if ($query->is_category([
"cartasemana", "carta-semana-pasada", "cartas-de-otras-semanas",
"letter-of-the-week", "lettre-de-la-semaine", "lettera-della-settimana", "carta-da-semana",
"carta-semana-pasada-en", "carta-semana-pasada-fr",
"carta-semana-pasada-it", "carta-semana-pasada-pt",
"letters-from-other-weeks", "lettres-des-autres-semaines",
"lettere-delle-altre-settimane", "cartas-de-outras-semanas",
])) {
$query->set("posts_per_page", 50);
}
});
@@ -0,0 +1,17 @@
<?php
/**
* Plugin Name: Fea Avatar Cache-bust (#81)
* Description: Añade ?v=<mtime> a las URLs de avatar servidas desde
* uploads/avatares/autores/autor-<uid>.png. Como al actualizar la foto se
* reescribe el MISMO fichero, sin esto el navegador/Cloudflare siguen sirviendo
* la versión cacheada. Corre tras el filtro de fea-homepage (prioridad 20).
*/
if (!defined('ABSPATH')) exit;
add_filter('get_avatar_url', function ($url, $id_or_email, $args) {
if (!is_string($url) || strpos($url, '/avatares/autores/autor-') === false) return $url;
$rel = preg_replace('~\?.*$~', '', substr($url, strpos($url, '/avatares/')));
$path = wp_get_upload_dir()['basedir'] . $rel;
if (file_exists($path)) $url = add_query_arg('v', filemtime($path), $url);
return $url;
}, 20, 3);
@@ -0,0 +1,291 @@
<?php
/**
* Plugin Name: Fe Adulta — Feedback Beta
* Description: Barra sutil de aviso "Beta" en todo el sitio + mini formulario (👍/👎 +
* comentario opcional) que se abre a demanda, para que el público ayude a
* encontrar errores. Guarda cada voto como "Beta Feedback" (CPT propio),
* legible en wp-admin en una sola lista. No usa el sistema de comentarios.
* Version: 1.1
*
* Ver issue rafa/feadulta#78.
*/
if (!defined('ABSPATH')) exit;
const FEA_FB_CPT = 'fea_feedback';
const FEA_FB_RATE_MAX = 12; // máximo de envíos por IP por hora
const FEA_FB_COMMENT_MAX = 2000;
/* ── 1) CPT donde se guardan los votos (solo backend) ─────────────────────── */
add_action('init', function () {
register_post_type(FEA_FB_CPT, [
'labels' => [
'name' => 'Beta Feedback',
'singular_name' => 'Feedback',
'menu_name' => 'Beta Feedback',
],
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'menu_icon' => 'dashicons-feedback',
'menu_position' => 26,
'capability_type' => 'post',
'capabilities' => ['create_posts' => 'do_not_allow'], // solo se crean por API
'map_meta_cap' => true,
'supports' => ['title', 'editor'],
'exclude_from_search' => true,
]);
});
/* ── 2a) Endpoint de consulta de idioma Polylang (para publicabot / integración externa) ── */
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/lang/(?P<id>\d+)', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function (WP_REST_Request $req) {
$id = (int) $req->get_param('id');
$post = get_post($id);
if (!$post || $post->post_type !== 'post') {
return new WP_REST_Response(['error' => 'post not found'], 404);
}
$langs = wp_get_object_terms($id, 'language', ['fields' => 'slugs']);
$lang = (!is_wp_error($langs) && !empty($langs)) ? $langs[0] : null;
return new WP_REST_Response(['id' => $id, 'lang' => $lang], 200);
},
]);
});
/* ── 2) Endpoint REST para recibir el voto ────────────────────────────────── */
add_action('rest_api_init', function () {
register_rest_route('fea/v1', '/feedback', [
'methods' => 'POST',
'permission_callback' => '__return_true', // público (Beta); protegido con honeypot + rate-limit
'callback' => 'fea_feedback_submit',
]);
});
function fea_feedback_submit(WP_REST_Request $req) {
// Honeypot: si el campo oculto viene relleno, es un bot.
if (trim((string) $req->get_param('website')) !== '') {
return new WP_REST_Response(['ok' => true], 200); // fingir éxito
}
$vote = $req->get_param('vote') === 'up' ? 'up' : ($req->get_param('vote') === 'down' ? 'down' : '');
if ($vote === '') {
return new WP_REST_Response(['ok' => false, 'error' => 'voto inválido'], 400);
}
// Rate-limit por IP.
$ip = fea_feedback_client_ip();
$key = 'fea_fb_rl_' . md5($ip);
$n = (int) get_transient($key);
if ($n >= FEA_FB_RATE_MAX) {
return new WP_REST_Response(['ok' => false, 'error' => 'demasiados envíos'], 429);
}
set_transient($key, $n + 1, HOUR_IN_SECONDS);
$comment = trim((string) $req->get_param('comment'));
$comment = mb_substr(wp_strip_all_tags($comment), 0, FEA_FB_COMMENT_MAX);
$url = esc_url_raw((string) $req->get_param('url'));
$src_id = (int) $req->get_param('post_id');
$lang = preg_replace('/[^a-z]/', '', (string) $req->get_param('lang'));
$title = (string) $req->get_param('title');
$emoji = $vote === 'up' ? '👍' : '👎';
$post_id = wp_insert_post([
'post_type' => FEA_FB_CPT,
'post_status' => 'private',
'post_title' => sprintf('%s %s', $emoji, $title ?: $url),
'post_content' => $comment,
], true);
if (is_wp_error($post_id)) {
return new WP_REST_Response(['ok' => false, 'error' => 'no se pudo guardar'], 500);
}
update_post_meta($post_id, '_fea_fb_vote', $vote);
update_post_meta($post_id, '_fea_fb_url', $url);
update_post_meta($post_id, '_fea_fb_source_id', $src_id);
update_post_meta($post_id, '_fea_fb_lang', $lang);
update_post_meta($post_id, '_fea_fb_ua', mb_substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255));
update_post_meta($post_id, '_fea_fb_ip', md5($ip)); // hash, no IP en claro
return new WP_REST_Response(['ok' => true], 200);
}
function fea_feedback_client_ip(): string {
foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_FORWARDED_FOR', 'REMOTE_ADDR'] as $k) {
if (!empty($_SERVER[$k])) return trim(explode(',', $_SERVER[$k])[0]);
}
return '0.0.0.0';
}
/* ── 3) Columnas en el listado del wp-admin ───────────────────────────────── */
add_filter('manage_' . FEA_FB_CPT . '_posts_columns', function ($cols) {
return [
'cb' => $cols['cb'] ?? '',
'fb_vote' => 'Voto',
'fb_url' => 'Página',
'fb_lang' => 'Idioma',
'fb_comment'=> 'Comentario',
'date' => 'Fecha',
];
});
add_action('manage_' . FEA_FB_CPT . '_posts_custom_column', function ($col, $post_id) {
if ($col === 'fb_vote') {
echo get_post_meta($post_id, '_fea_fb_vote', true) === 'up' ? '👍' : '👎';
} elseif ($col === 'fb_url') {
$u = get_post_meta($post_id, '_fea_fb_url', true);
if ($u) echo '<a href="' . esc_url($u) . '" target="_blank" rel="noopener">' . esc_html(wp_parse_url($u, PHP_URL_PATH) ?: $u) . '</a>';
} elseif ($col === 'fb_lang') {
echo esc_html(strtoupper(get_post_meta($post_id, '_fea_fb_lang', true) ?: '—'));
} elseif ($col === 'fb_comment') {
echo esc_html(wp_trim_words(get_post_field('post_content', $post_id), 24));
}
}, 10, 2);
/* ── 4) Barra Beta sutil (persistente) + tarjeta de feedback (a demanda) ──── */
/** Etiquetas del widget Beta por idioma (Polylang). */
function fea_beta_labels(): array {
$all = [
'es' => ['region'=>'Aviso Beta','intro'=>'Estamos en','help'=>'¿Nos ayudas a mejorar FeAdulta?',
'opinion'=>'Dar mi opinión','collab'=>'Colaborar','dismiss'=>'Cerrar aviso','fbregion'=>'Feedback de la página',
'close'=>'Cerrar','q'=>'¿Se ve bien esta página?','up'=>'Sí, se ve bien','down'=>'No, hay algo mal',
'ph'=>'¿Algo falla o se ve mal? Cuéntanoslo (opcional)','send'=>'Enviar','thanks'=>'¡Gracias por ayudar! 🙏'],
'en' => ['region'=>'Beta notice','intro'=>'We are in','help'=>'Will you help us improve FeAdulta?',
'opinion'=>'Give feedback','collab'=>'Collaborate','dismiss'=>'Close notice','fbregion'=>'Page feedback',
'close'=>'Close','q'=>'Does this page look right?','up'=>'Yes, looks good','down'=>'No, something is wrong',
'ph'=>'Something broken or off? Tell us (optional)','send'=>'Send','thanks'=>'Thanks for helping! 🙏'],
'fr' => ['region'=>'Avis Bêta','intro'=>'Nous sommes en','help'=>'Voulez-vous nous aider à améliorer FeAdulta ?',
'opinion'=>'Donner mon avis','collab'=>'Collaborer','dismiss'=>'Fermer lavis','fbregion'=>'Retour sur la page',
'close'=>'Fermer','q'=>'Cette page saffiche-t-elle bien ?','up'=>'Oui, cest bien','down'=>'Non, il y a un problème',
'ph'=>'Un souci ou un affichage incorrect ? Dites-le-nous (facultatif)','send'=>'Envoyer','thanks'=>'Merci de votre aide ! 🙏'],
'it' => ['region'=>'Avviso Beta','intro'=>'Siamo in','help'=>'Ci aiuti a migliorare FeAdulta?',
'opinion'=>'Dai la tua opinione','collab'=>'Collabora','dismiss'=>'Chiudi avviso','fbregion'=>'Feedback della pagina',
'close'=>'Chiudi','q'=>'Questa pagina si vede bene?','up'=>'Sì, si vede bene','down'=>'No, c’è qualcosa che non va',
'ph'=>'Qualcosa non va o si vede male? Faccelo sapere (facoltativo)','send'=>'Invia','thanks'=>'Grazie per laiuto! 🙏'],
'pt' => ['region'=>'Aviso Beta','intro'=>'Estamos em','help'=>'Ajuda-nos a melhorar a FeAdulta?',
'opinion'=>'Dar a minha opinião','collab'=>'Colaborar','dismiss'=>'Fechar aviso','fbregion'=>'Feedback da página',
'close'=>'Fechar','q'=>'Esta página vê-se bem?','up'=>'Sim, vê-se bem','down'=>'Não, há algo errado',
'ph'=>'Algo falha ou vê-se mal? Conta-nos (opcional)','send'=>'Enviar','thanks'=>'Obrigado por ajudar! 🙏'],
];
$lang = function_exists('pll_current_language') ? (string) pll_current_language() : 'es';
return $all[$lang] ?? $all['es'];
}
add_action('wp_footer', function () {
if (is_admin()) return;
$rest = esc_url_raw(rest_url('fea/v1/feedback'));
$t = fea_beta_labels();
?>
<style>
/* Barra sutil de aviso Beta, abajo, full-width */
#fea-beta-bar { position: fixed; left: 0; right: 0; bottom: 0; z-index: 99997;
background: #faf6f2; border-top: 1px solid #e6ddd5; color: #4a3b34;
font-family: inherit; font-size: .86rem; line-height: 1.3;
padding: 8px 44px 8px 16px; text-align: center; }
#fea-beta-bar strong { color: #8b1a2e; }
#fea-beta-bar .fea-beta-open { margin-left: 10px; cursor: pointer; border: 1px solid #8b1a2e;
background: #8b1a2e; color: #fff; border-radius: 6px; padding: 4px 12px; font-size: .82rem; font-weight: 600; }
#fea-beta-bar .fea-beta-open:hover { background: #761526; }
#fea-beta-bar .fea-beta-collab { margin-left: 8px; cursor: pointer; display: inline-block;
border: 1px solid #1b7a34; background: #1b7a34; color: #fff; border-radius: 6px;
padding: 4px 12px; font-size: .82rem; font-weight: 600; text-decoration: none; }
#fea-beta-bar .fea-beta-collab:hover { background: #15642a; }
#fea-beta-bar .fea-beta-dismiss { position: absolute; right: 10px; top: 50%; transform: translateY(-50%);
border: 0; background: none; font-size: 1.15rem; cursor: pointer; color: #8a7a72; padding: 2px 6px; line-height: 1; }
#fea-beta-bar.hidden { display: none; }
/* Tarjeta de feedback: oculta hasta que el usuario la abre desde la barra */
#fea-fb { position: fixed; right: 16px; bottom: 56px; z-index: 99998; font-family: inherit; max-width: 300px; }
#fea-fb[hidden] { display: none; }
#fea-fb .fea-fb-card { background:#fff; border:1px solid #e2e2e2; border-radius:12px;
box-shadow:0 8px 28px rgba(0,0,0,.16); padding:12px 14px; font-size:.9rem; color:#222; position:relative; }
#fea-fb .fea-fb-q { margin:0 0 8px; line-height:1.3; padding-right:16px; }
#fea-fb .fea-fb-btns { display:flex; gap:8px; }
#fea-fb button.fea-fb-vote { cursor:pointer; border:1px solid #ccc; background:#fafafa; border-radius:8px;
padding:6px 12px; font-size:1.05rem; line-height:1; }
#fea-fb button.fea-fb-vote:hover { background:#f0f0f0; }
#fea-fb button.fea-fb-vote.sel { border-color:#8b1a2e; background:#f7e9ec; }
#fea-fb textarea { width:100%; margin:9px 0 8px; border:1px solid #ccc; border-radius:8px; padding:7px;
font:inherit; font-size:.85rem; resize:vertical; min-height:58px; box-sizing:border-box; }
#fea-fb .fea-fb-send { background:#8b1a2e; color:#fff; border:1px solid #8b1a2e; border-radius:8px;
padding:6px 12px; font-size:.85rem; width:100%; cursor:pointer; }
#fea-fb .fea-fb-hp { position:absolute; left:-9999px; }
#fea-fb .fea-fb-close { position:absolute; top:4px; right:8px; border:0; background:none; font-size:1rem; cursor:pointer; padding:2px 4px; line-height:1; }
@media (max-width:600px){ #fea-fb{ right:10px; left:10px; max-width:none; } #fea-beta-bar{ font-size:.8rem; } }
</style>
<div id="fea-beta-bar" class="hidden" role="region" aria-label="<?php echo esc_attr($t['region']); ?>">
🌱 <?php echo esc_html($t['intro']); ?> <strong>Beta</strong>. <?php echo esc_html($t['help']); ?>
<button type="button" class="fea-beta-open"><?php echo esc_html($t['opinion']); ?></button>
<a class="fea-beta-collab" href="https://edicionesfeadulta.com/colabora/" target="_blank" rel="noopener"><?php echo esc_html($t['collab']); ?></a>
<button type="button" class="fea-beta-dismiss" aria-label="<?php echo esc_attr($t['dismiss']); ?>">×</button>
</div>
<div id="fea-fb" hidden role="complementary" aria-label="<?php echo esc_attr($t['fbregion']); ?>">
<div class="fea-fb-card">
<button type="button" class="fea-fb-close" aria-label="<?php echo esc_attr($t['close']); ?>">×</button>
<p class="fea-fb-q"><?php echo esc_html($t['q']); ?></p>
<div class="fea-fb-btns">
<button type="button" class="fea-fb-vote" data-vote="up" aria-label="<?php echo esc_attr($t['up']); ?>">👍</button>
<button type="button" class="fea-fb-vote" data-vote="down" aria-label="<?php echo esc_attr($t['down']); ?>">👎</button>
</div>
<div class="fea-fb-more" hidden>
<input type="text" class="fea-fb-hp" tabindex="-1" autocomplete="off" aria-hidden="true" placeholder="No rellenar">
<textarea placeholder="<?php echo esc_attr($t['ph']); ?>"></textarea>
<button type="button" class="fea-fb-send"><?php echo esc_html($t['send']); ?></button>
</div>
<div class="fea-fb-thanks" hidden><?php echo esc_html($t['thanks']); ?></div>
</div>
</div>
<script>
(function(){
var bar = document.getElementById('fea-beta-bar');
var box = document.getElementById('fea-fb');
if(!bar || !box) return;
var REST = <?php echo json_encode($rest); ?>;
var pid = <?php echo (int) (is_singular() ? get_queried_object_id() : 0); ?>;
var lang = <?php echo json_encode(function_exists('pll_current_language') ? (string) pll_current_language() : ''); ?>;
var chosen = null;
var moreEl = box.querySelector('.fea-fb-more');
var votes = box.querySelectorAll('.fea-fb-vote');
var thanks = box.querySelector('.fea-fb-thanks');
// Mostrar la barra salvo que el usuario la haya descartado antes.
try { if (!localStorage.getItem('fea_beta_bar_off')) bar.classList.remove('hidden'); }
catch(e){ bar.classList.remove('hidden'); }
function openCard(){ box.hidden = false; }
function closeCard(){ box.hidden = true; }
bar.querySelector('.fea-beta-open').addEventListener('click', openCard);
bar.querySelector('.fea-beta-dismiss').addEventListener('click', function(){
bar.classList.add('hidden');
try { localStorage.setItem('fea_beta_bar_off','1'); } catch(e){}
});
box.querySelector('.fea-fb-close').addEventListener('click', closeCard);
votes.forEach(function(b){ b.addEventListener('click', function(){
chosen = b.getAttribute('data-vote');
votes.forEach(function(x){ x.classList.toggle('sel', x===b); });
moreEl.hidden = false;
});});
box.querySelector('.fea-fb-send').addEventListener('click', function(){
if(!chosen) return;
var hp = box.querySelector('.fea-fb-hp').value;
var comment = box.querySelector('textarea').value;
fetch(REST, { method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ vote:chosen, comment:comment, url:location.href, post_id:pid,
lang:lang, title:document.title, website:hp }) }).catch(function(){});
box.querySelector('.fea-fb-btns').hidden = true;
box.querySelector('.fea-fb-q').hidden = true;
moreEl.hidden = true; thanks.hidden = false;
setTimeout(closeCard, 2200);
});
})();
</script>
<?php
}, 40);
@@ -0,0 +1,142 @@
<?php
/**
* Plugin Name: Fe Adulta — API carta_id
* Description: Endpoint REST para que Inma y su asistente asignen el meta interno _carta_id.
* Version: 1.0
*/
add_action('rest_api_init', function() {
register_rest_route('fea/v1', '/carta-id/(?P<post_id>\d+)', [
[
'methods' => WP_REST_Server::READABLE,
'callback' => 'fea_carta_id_api_get',
'permission_callback' => 'fea_carta_id_api_can_edit_post',
'args' => fea_carta_id_api_route_args(),
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'fea_carta_id_api_update',
'permission_callback' => 'fea_carta_id_api_can_edit_post',
'args' => array_merge(fea_carta_id_api_route_args(), [
'carta_id' => [
'required' => true,
],
]),
],
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => 'fea_carta_id_api_delete',
'permission_callback' => 'fea_carta_id_api_can_edit_post',
'args' => fea_carta_id_api_route_args(),
],
]);
});
function fea_carta_id_api_route_args() {
return [
'post_id' => [
'required' => true,
],
];
}
function fea_carta_id_api_can_edit_post(WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new WP_Error(
'fea_carta_id_not_authenticated',
'Debes autenticarte para leer o modificar _carta_id.',
['status' => 401]
);
}
$post_id = (int) $request['post_id'];
if (!fea_carta_id_api_post_exists($post_id)) {
return true;
}
if (!current_user_can('edit_post', $post_id)) {
return new WP_Error(
'fea_carta_id_forbidden',
'No tienes permiso para editar este post.',
['status' => 403]
);
}
return true;
}
function fea_carta_id_api_get(WP_REST_Request $request) {
$post_id = (int) $request['post_id'];
$error = fea_carta_id_api_validate_post_id($post_id);
if ($error) return $error;
return fea_carta_id_api_response($post_id);
}
function fea_carta_id_api_update(WP_REST_Request $request) {
$post_id = (int) $request['post_id'];
$error = fea_carta_id_api_validate_post_id($post_id);
if ($error) return $error;
$carta_id = fea_carta_id_api_parse_positive_int($request->get_param('carta_id'));
if (!$carta_id) {
return new WP_Error(
'fea_carta_id_invalid',
'carta_id debe ser un entero positivo.',
['status' => 400]
);
}
if (!fea_carta_id_api_post_exists($carta_id)) {
return new WP_Error(
'fea_carta_id_not_found',
'carta_id debe corresponder a un post existente.',
['status' => 400]
);
}
update_post_meta($post_id, '_carta_id', $carta_id);
return fea_carta_id_api_response($post_id);
}
function fea_carta_id_api_delete(WP_REST_Request $request) {
$post_id = (int) $request['post_id'];
$error = fea_carta_id_api_validate_post_id($post_id);
if ($error) return $error;
delete_post_meta($post_id, '_carta_id');
return fea_carta_id_api_response($post_id);
}
function fea_carta_id_api_validate_post_id($post_id) {
if (!fea_carta_id_api_post_exists($post_id)) {
return new WP_Error(
'fea_carta_id_post_not_found',
'post_id debe corresponder a un post existente.',
['status' => 404]
);
}
return null;
}
function fea_carta_id_api_post_exists($post_id) {
$post = get_post($post_id);
return $post && $post->post_type === 'post';
}
function fea_carta_id_api_parse_positive_int($value) {
$int = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
return $int === false ? null : (int) $int;
}
function fea_carta_id_api_response($post_id) {
$carta_id = fea_carta_id_api_parse_positive_int(get_post_meta($post_id, '_carta_id', true));
return rest_ensure_response([
'post_id' => (int) $post_id,
'carta_id' => $carta_id,
]);
}
@@ -140,9 +140,21 @@ function fea_url_to_post_id($url) {
// Enlace interno WP: deriva el slug del path, relativo al home. Agnóstico al
// entorno → funciona en local (home en .../fea) y en prod (home en la raíz).
// No depende de un prefijo /fea/ hardcodeado (issue #91).
$host = wp_parse_url($url, PHP_URL_HOST);
$home_host = wp_parse_url(home_url(), PHP_URL_HOST);
if ($host && $home_host && strcasecmp($host, $home_host) !== 0) {
//
// Durante el cutover de dominio (issue #158) el `home_url()` de WP sigue en
// wp-nuevo.feadulta.com pero el contenido ya enlaza a www.feadulta.com. Para
// no romper la resolución mientras el cutover no esté cerrado, se acepta
// también una lista fija de hosts propios del sitio, no solo home_url().
// TODO: cuando #158 quede resuelto y home_url() sea www.feadulta.com, esta
// lista se puede simplificar a solo home_url().
$own_hosts = array_filter(array_unique([
strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)),
'www.feadulta.com',
'feadulta.com',
'wp-nuevo.feadulta.com',
]));
$host = wp_parse_url($url, PHP_URL_HOST);
if ($host && !in_array(strtolower($host), $own_hosts, true)) {
return null; // host externo → no es un artículo nuestro
}
@@ -0,0 +1,76 @@
<?php
/**
* Plugin Name: FEA Cloudflare Real IP
* Description: Restaura la IP real del visitante en REMOTE_ADDR cuando la conexión
* proviene de un rango oficial de Cloudflare, leyendo CF-Connecting-IP.
* Necesario para que plugins de seguridad (LLAR), analytics y comentarios
* vean la IP del usuario y no la del proxy de Cloudflare.
* Auto-correctivo: si la conexión NO viene de Cloudflare, no toca nada.
* Version: 1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Rangos oficiales de Cloudflare (https://www.cloudflare.com/ips/).
$fea_cf_ipv4 = array(
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
'104.24.0.0/14', '172.64.0.0/13', '131.0.72.0/22',
);
$fea_cf_ipv6 = array(
'2400:cb00::/32', '2606:4700::/32', '2803:f800::/32', '2405:b500::/32',
'2405:8100::/32', '2a06:98c0::/29', '2c0f:f248::/32',
);
if ( empty( $_SERVER['REMOTE_ADDR'] ) || empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
return;
}
$fea_remote = $_SERVER['REMOTE_ADDR'];
$fea_client = trim( $_SERVER['HTTP_CF_CONNECTING_IP'] );
/**
* Comprueba si una IP está dentro de un CIDR (IPv4 o IPv6).
*/
function fea_ip_in_cidr( $ip, $cidr ) {
list( $subnet, $bits ) = array_pad( explode( '/', $cidr, 2 ), 2, null );
if ( null === $bits ) {
return false;
}
$bits = (int) $bits;
$ip_bin = @inet_pton( $ip );
$subnet_bin = @inet_pton( $subnet );
if ( false === $ip_bin || false === $subnet_bin || strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
return false;
}
$bytes = intdiv( $bits, 8 );
$rem = $bits % 8;
if ( $bytes > 0 && 0 !== substr_compare( $ip_bin, $subnet_bin, 0, $bytes ) ) {
return false;
}
if ( $rem > 0 ) {
$mask = ~( ( 1 << ( 8 - $rem ) ) - 1 ) & 0xff;
if ( ( ord( $ip_bin[ $bytes ] ) & $mask ) !== ( ord( $subnet_bin[ $bytes ] ) & $mask ) ) {
return false;
}
}
return true;
}
$fea_ranges = ( false !== strpos( $fea_remote, ':' ) ) ? $fea_cf_ipv6 : $fea_cf_ipv4;
$fea_from_cf = false;
foreach ( $fea_ranges as $fea_cidr ) {
if ( fea_ip_in_cidr( $fea_remote, $fea_cidr ) ) {
$fea_from_cf = true;
break;
}
}
// Solo confiamos en CF-Connecting-IP si la conexión proviene de Cloudflare,
// y solo si el valor es una IP válida (evita inyecciones).
if ( $fea_from_cf && false !== filter_var( $fea_client, FILTER_VALIDATE_IP ) ) {
$_SERVER['REMOTE_ADDR'] = $fea_client;
}
@@ -0,0 +1,67 @@
<?php
/**
* Plugin Name: Fe Adulta - compact entry spacing
* Description: Ajusta el aire vertical de la navegacion de entradas y la paginacion de archivos.
*/
add_action('wp_head', function() {
if (is_admin()) return;
if (!(is_single() || is_archive() || is_search() || is_home())) return;
?>
<style>
/* Issue #67: compactar el cierre del single post sin tocar el template FSE. */
body.single-post .wp-block-group.alignwide:has(> nav[aria-label="Navegación de entradas"]) {
margin-top: 1rem !important;
margin-bottom: 0.75rem !important;
}
body.single-post nav[aria-label="Navegación de entradas"] {
padding-top: 0.75rem !important;
padding-bottom: 0.75rem !important;
gap: 1rem !important;
}
body.single-post .wp-block-post-navigation-link {
line-height: 1.35;
}
body.single-post .wp-block-post-navigation-link a {
text-decoration-thickness: 1px;
text-underline-offset: 3px;
}
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) {
padding-top: 1rem !important;
padding-bottom: 1.25rem !important;
}
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) > .wp-block-heading {
margin-bottom: 0.65rem !important;
}
body.single-post .wp-block-group.alignwide:has(> .wp-block-heading + .wp-block-query) .wp-block-post-template > .wp-block-post {
margin-block-start: 0 !important;
}
/* Issue #67: paginacion de archivos/categorias ("Mas entradas" / siguiente pagina). */
body.archive .wp-block-query.alignwide > .wp-block-spacer,
body.search .wp-block-query.alignwide > .wp-block-spacer,
body.blog .wp-block-query.alignwide > .wp-block-spacer {
height: 0.5rem !important;
}
body.archive .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination),
body.search .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination),
body.blog .wp-block-query.alignwide > .wp-block-group.alignfull:has(> .wp-block-query-pagination) {
margin-top: 0.5rem !important;
margin-bottom: 0.75rem !important;
}
body.archive .wp-block-query-pagination,
body.search .wp-block-query-pagination,
body.blog .wp-block-query-pagination {
gap: 0.75rem 1.25rem !important;
align-items: center;
}
body.archive .wp-block-query-pagination-numbers,
body.search .wp-block-query-pagination-numbers,
body.blog .wp-block-query-pagination-numbers {
display: flex;
gap: 0.45rem;
align-items: center;
}
</style>
<?php
}, 30);
@@ -0,0 +1,29 @@
<?php
/**
* fea-disable-comments — feadulta no usa comentarios.
* Defensivo: aunque un post quede con comment_status=open por accidente,
* el render trata comentarios y pings como cerrados y no muestra UI.
*/
// Comentarios y pings siempre cerrados en el frontend.
add_filter('comments_open', '__return_false', 20, 2);
add_filter('pings_open', '__return_false', 20, 2);
// No devolver comentarios existentes al render.
add_filter('comments_array', '__return_empty_array', 10, 2);
// Quitar el soporte de comentarios de los tipos de contenido.
add_action('init', function () {
remove_post_type_support('post', 'comments');
remove_post_type_support('post', 'trackbacks');
remove_post_type_support('page', 'comments');
remove_post_type_support('page', 'trackbacks');
});
// Quitar "Comentarios" de la barra de admin.
add_action('wp_before_admin_bar_render', function () {
if (is_admin_bar_showing()) {
global $wp_admin_bar;
$wp_admin_bar->remove_menu('comments');
}
});
@@ -0,0 +1,13 @@
<?php
/**
* Plugin Name: Fea Search Console Verification
* Description: Mantiene la verificación de Google Search Console para
* www.feadulta.com tras el cutover Joomla -> WordPress (la plantilla de
* Joomla la tenía hardcodeada; en WP no había ningún sitio equivalente).
*/
if (!defined('ABSPATH')) exit;
add_action('wp_head', function () {
if (is_admin()) return;
echo '<meta name="google-site-verification" content="wZ_edk-78QW3w8aXtiYpzTPEJ8_dh8MkhaIDSTq8U-8" />' . "\n";
}, 1);
@@ -0,0 +1,66 @@
<?php
/**
* Plugin Name: Fe Adulta - hide imported artifacts
* Description: Oculta en frontend artefactos importados hasta decidir una limpieza definitiva.
*/
function fea_current_request_path() {
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
return is_string($path) ? trim($path, '/') : '';
}
function fea_is_bad_imported_request_path() {
$path = fea_current_request_path();
return (bool)preg_match('~(^|/)tag/1/?$~', $path)
|| (bool)preg_match('~(^|/)[0-9]{2}-[0-9]{2}-[0-9]{4}/?$~', $path);
}
function fea_is_bad_imported_tag($term) {
return $term
&& isset($term->taxonomy, $term->slug, $term->name)
&& $term->taxonomy === 'post_tag'
&& ($term->slug === '1' || $term->name === '1');
}
add_filter('get_the_terms', function($terms, $post_id, $taxonomy) {
if (is_admin() || $taxonomy !== 'post_tag' || empty($terms) || is_wp_error($terms)) {
return $terms;
}
return array_values(array_filter($terms, function($term) {
return !fea_is_bad_imported_tag($term);
}));
}, 10, 3);
add_filter('get_terms', function($terms, $taxonomies) {
if (is_admin() || is_wp_error($terms) || empty($terms) || !in_array('post_tag', (array)$taxonomies, true)) {
return $terms;
}
return array_values(array_filter($terms, function($term) {
return !fea_is_bad_imported_tag($term);
}));
}, 10, 2);
add_filter('redirect_canonical', function($redirect_url) {
return fea_is_bad_imported_request_path() ? false : $redirect_url;
}, 10);
add_filter('do_redirect_guess_404_permalink', function($do_redirect) {
return fea_is_bad_imported_request_path() ? false : $do_redirect;
}, 10);
add_filter('wp_redirect', function($location) {
return fea_is_bad_imported_request_path() ? false : $location;
}, 0);
add_action('template_redirect', function() {
if (!is_tag('1') && !fea_is_bad_imported_request_path()) {
return;
}
global $wp_query;
$wp_query->set_404();
status_header(404);
nocache_headers();
}, 0);
@@ -790,9 +790,40 @@ function fea_title(string $title): string {
$out = preg_replace_callback('/([\/:¿¡] *)(\p{Ll})/u', function ($m) {
return $m[1] . mb_strtoupper($m[2], 'UTF-8');
}, $out);
$out = fea_recapitalizar_nombres_propios($out);
return $out;
}
/**
* Nombres propios (personas, lugares, "Dios"/"Papa") que deben mantener mayúscula
* inicial en los títulos de portada. Los títulos originales se guardan en MAYÚSCULAS
* y fea_title() los minusculiza salvo la primera letra, así que esta señal se pierde
* para cualquier nombre propio que no sea la primera palabra del título. Añadir aquí
* cuando una carta nueva mencione un nombre que salga mal capitalizado en portada.
* Ver issue rafa/feadulta#160.
*/
function fea_nombres_propios(): array {
return [
'Aguirre', 'Crepin', 'Monga', 'Papa', 'Lampedusa', 'Europa', 'España', 'Dios',
];
}
function fea_recapitalizar_nombres_propios(string $texto): string {
static $regex = null, $map = null;
if ($regex === null) {
$nombres = fea_nombres_propios();
usort($nombres, fn($a, $b) => mb_strlen($b, 'UTF-8') - mb_strlen($a, 'UTF-8'));
$alt = implode('|', array_map(fn($n) => preg_quote($n, '/'), $nombres));
$regex = '/(?<![\p{L}])(' . $alt . ')(?![\p{L}])/iu';
$map = [];
foreach ($nombres as $n) $map[mb_strtolower($n, 'UTF-8')] = $n;
}
return preg_replace_callback($regex, function ($m) use ($map) {
$k = mb_strtolower($m[1], 'UTF-8');
return $map[$k] ?? $m[1];
}, $texto);
}
/** Lista de libros bíblicos (para avatar genérico de lecturas/eucaristías). #61 */
function fea_libros_biblicos(): array {
return [
@@ -818,6 +849,14 @@ function fea_evangelista_de_texto(string $txt): string {
return '';
}
/** Devuelve 'antiguo-testamento' / 'nuevo-testamento' si el texto es esa firma, o ''. #66 */
function fea_testamento_de_texto(string $txt): string {
$t = mb_strtolower(trim($txt), 'UTF-8');
if ($t === 'antiguo testamento') return 'antiguo-testamento';
if ($t === 'nuevo testamento') return 'nuevo-testamento';
return '';
}
/** True si el texto empieza por un libro bíblico (cita) o es el nombre de un libro. #61 */
function fea_es_libro_biblico(string $txt): bool {
$txt = trim($txt);
@@ -850,6 +889,8 @@ function fea_avatar_url(object $post, int $size, int $author_id, string $author_
}
}
if ($ev = fea_evangelista_de_texto($title)) return $base . $ev . '.svg';
// "Antiguo Testamento" / "Nuevo Testamento" llevan símbolo propio (no el genérico). #66
if ($t = fea_testamento_de_texto($title) ?: fea_testamento_de_texto($author_name)) return $base . $t . '.svg';
if (fea_es_libro_biblico($title) || fea_es_libro_biblico($author_name)) return $base . 'biblia.svg';
return get_avatar_url($author_id, ['size' => $size, 'default' => 'identicon']);
}
@@ -0,0 +1,24 @@
<?php
/**
* URLs de Joomla que no tengan equivalente en WordPress (404 genuino) se
* redirigen a antiguo.feadulta.com, donde Joomla se sigue sirviendo tras el
* cutover, en vez de mostrar el 404 de WordPress.
*/
add_action('template_redirect', function () {
if (!is_404()) {
return;
}
$path = untrailingslashit(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));
// La antigua home de Joomla (con prefijo de idioma /es/) debe llevar a
// la home nueva, no a la web antigua.
if ($path === '/es') {
wp_redirect(home_url('/'), 301);
exit;
}
wp_redirect('https://antiguo.feadulta.com' . $_SERVER['REQUEST_URI'], 301);
exit;
}, 0);
@@ -0,0 +1,159 @@
<?php
/**
* fea-menu-i18n — Traducción de los menús FSE (issue #120).
*
* Polylang FREE no traduce los bloques `wp_navigation` (menús del header y pie).
* Este plugin engancha `render_block` sobre cada `core/navigation-link` /
* `core/navigation-submenu` y, cuando el idioma actual ≠ es:
* 1) sustituye la ETIQUETA por su traducción (mapa de abajo, hecho a mano),
* 2) remapea la URL al destino traducido si existe (post/página/categoría via
* Polylang); si no hay traducción del destino, deja la URL ES (fallback).
*
* Las etiquetas son cortas y de contexto religioso → traducidas a mano para
* máxima calidad (el contenido largo de #120 va por MiniMax + glosario).
*/
// NOTA: los mu-plugins cargan antes que Polylang → NO comprobar pll_* a nivel de
// fichero (abortaría). Se comprueba dentro del filtro, en render (ya cargado).
/** Mapa etiqueta ES => [en, fr, it, pt]. Claves normalizadas con trim. */
function fea_menu_map(): array {
static $m = null;
if ($m !== null) return $m;
$m = [
// ── Menú principal (header) ──
'PORTADA' => ['Home', 'Accueil', 'Home', 'Início'],
'Quiénes somos' => ['About us', 'À propos', 'Chi siamo', 'Quem somos'],
'Colaboradores' => ['Contributors', 'Collaborateurs', 'Collaboratori', 'Colaboradores'],
'Este portal' => ['This portal', 'Ce portail', 'Questo portale', 'Este portal'],
'Para poner al día la Fe' => ['Bringing faith up to date', 'Mettre la foi à jour', 'Aggiornare la fede', 'Atualizar a fé'],
'Cartas' => ['Letters', 'Lettres', 'Lettere', 'Cartas'],
'Esta semana' => ['This week', 'Cette semaine', 'Questa settimana', 'Esta semana'],
'Semana pasada' => ['Last week', 'Semaine dernière', 'Settimana scorsa', 'Semana passada'],
'Otras semanas' => ['Other weeks', 'Autres semaines', 'Altre settimane', 'Outras semanas'],
'Acceso a webs anteriores:' => ['Previous websites:', 'Anciens sites :', 'Siti precedenti:', 'Sites anteriores:'],
'Web V1 — FrontPage (2006-2012)' => ['Site V1 — FrontPage (2006-2012)', 'Site V1 — FrontPage (2006-2012)', 'Sito V1 — FrontPage (2006-2012)', 'Site V1 — FrontPage (2006-2012)'],
'Web V2 — Joomla (2012-2026)' => ['Site V2 — Joomla (2012-2026)', 'Site V2 — Joomla (2012-2026)', 'Sito V2 — Joomla (2012-2026)', 'Site V2 — Joomla (2012-2026)'],
'Nueva política de datos' => ['New data policy', 'Nouvelle politique de données', 'Nuova politica sui dati', 'Nova política de dados'],
'Contactar' => ['Contact', 'Contact', 'Contatti', 'Contactar'],
'Para contactar con nosotros' => ['Contact us', 'Nous contacter', 'Per contattarci', 'Para contactar-nos'],
'Para recibir la carta de novedades' => ['Subscribe to the newsletter', 'Recevoir la newsletter', 'Ricevere la newsletter', 'Receber a newsletter'],
'Para inscribirse en la Escuela' => ['Enrol in the School', 'S\'inscrire à l\'École', 'Iscriversi alla Scuola', 'Inscrever-se na Escola'],
'🎓 Escuela' => ['🎓 School', '🎓 École', '🎓 Scuola', '🎓 Escola'],
'📚 Librería 🛒' => ['📚 Bookshop 🛒', '📚 Librairie 🛒', '📚 Libreria 🛒', '📚 Livraria 🛒'],
'Buscar' => ['Search', 'Rechercher', 'Cerca', 'Pesquisar'],
// ── Menús del pie ──
'Cartas que nos llegan' => ['Letters we receive', 'Lettres que nous recevons', 'Lettere che riceviamo', 'Cartas que recebemos'],
'Tablón de anuncios' => ['Notice board', 'Tableau d\'annonces', 'Bacheca', 'Mural de avisos'],
'Asociación FeAdulta' => ['FeAdulta Association', 'Association FeAdulta', 'Associazione FeAdulta', 'Associação FeAdulta'],
'La suma de todos' => ['The sum of all', 'La somme de tous', 'La somma di tutti', 'A soma de todos'],
'Comunidades cristianas' => ['Christian communities', 'Communautés chrétiennes', 'Comunità cristiane', 'Comunidades cristãs'],
'El Evangelio de cada día' => ['The daily Gospel', 'L\'Évangile de chaque jour', 'Il Vangelo di ogni giorno', 'O Evangelho de cada dia'],
'Índice cronológico' => ['Chronological index', 'Index chronologique', 'Indice cronologico', 'Índice cronológico'],
'Índice cronológico' => ['Chronological index', 'Index chronologique', 'Indice cronologico', 'Índice cronológico'],
'Evangelios y comentarios' => ['Gospels and commentaries', 'Évangiles et commentaires', 'Vangeli e commenti', 'Evangelhos e comentários'],
'Oraciones eucarísticas' => ['Eucharistic prayers', 'Prières eucharistiques', 'Preghiere eucaristiche', 'Orações eucarísticas'],
'A modo de salmos' => ['In the manner of psalms', 'À la manière de psaumes', 'A mo\' di salmi', 'À maneira de salmos'],
'Preces y oraciones varias' => ['Prayers and various orations', 'Prières et oraisons diverses', 'Preci e orazioni varie', 'Preces e orações várias'],
'Primeras lecturas' => ['First readings', 'Premières lectures', 'Prime letture', 'Primeiras leituras'],
'Autores' => ['Authors', 'Auteurs', 'Autori', 'Autores'],
'Temas' => ['Topics', 'Thèmes', 'Temi', 'Temas'],
'Multimedia' => ['Multimedia', 'Multimédia', 'Multimedia', 'Multimédia'],
'Índice de pensamientos' => ['Index of reflections', 'Index des pensées', 'Indice dei pensieri', 'Índice de pensamentos'],
'Cantoral' => ['Hymnal', 'Recueil de chants', 'Canzoniere', 'Cancioneiro'],
'Películas' => ['Films', 'Films', 'Film', 'Filmes'],
'Reseñas de libros' => ['Book reviews', 'Critiques de livres', 'Recensioni di libri', 'Resenhas de livros'],
'In memoriam' => ['In memoriam', 'In memoriam', 'In memoriam', 'In memoriam'],
];
return $m;
}
/** Índice de columna por idioma. */
function fea_menu_lang_col(string $lang): int {
return ['en' => 0, 'fr' => 1, 'it' => 2, 'pt' => 3][$lang] ?? -1;
}
function fea_menu_tr(string $label, int $col): ?string {
$map = fea_menu_map();
$key = trim($label);
if (isset($map[$key][$col]) && $map[$key][$col] !== '') return $map[$key][$col];
return null;
}
/**
* Remapea una URL ES al destino traducido si existe (post/página/categoría).
* Devuelve la URL original si no hay traducción o no se resuelve.
*/
function fea_menu_localize_url(string $url, string $lang): string {
if ($url === '' || preg_match('~^(mailto:|tel:|javascript:|#)~i', $url)) return $url;
// Solo enlaces internos de ESTE sitio (no Librería, no webs anteriores externas).
$host = parse_url($url, PHP_URL_HOST);
if ($host) {
$site_host = parse_url(home_url('/'), PHP_URL_HOST);
if ($host !== $site_host) return $url; // externo
}
$path = trim((string) parse_url($url, PHP_URL_PATH), '/');
// quitar subcarpeta local (fea) y prefijo de idioma (es/en/…), con o sin barra final
$path = preg_replace('#^fea(/|$)#', '', $path);
$path = preg_replace('#^(es|en|fr|it|pt)(/|$)#', '', $path);
if ($path === '') {
// raíz del sitio → portada del idioma
return function_exists('pll_home_url') ? pll_home_url($lang) : $url;
}
// categoría: category/<slug>. El slug del menú es ES; buscamos SIN filtro de
// idioma de Polylang (lang => '') porque el render va en otro idioma.
if (preg_match('#^category/([^/]+)/?$#', $path, $mm)) {
$terms = get_terms(['taxonomy' => 'category', 'slug' => $mm[1], 'hide_empty' => false, 'number' => 1, 'lang' => '']);
$term = (!is_wp_error($terms) && $terms) ? $terms[0] : null;
if ($term && function_exists('pll_get_term')) {
$tr = pll_get_term($term->term_id, $lang);
if ($tr) {
$link = get_category_link($tr);
if ($link && !is_wp_error($link)) return $link;
}
}
return $url;
}
// página/post por último segmento (lang => '' para no filtrar por idioma actual)
$slug = basename($path);
$q = get_posts(['name' => $slug, 'post_type' => ['post', 'page'], 'numberposts' => 1, 'post_status' => 'publish', 'lang' => '']);
$page = $q ? $q[0] : null;
if ($page && function_exists('pll_get_post')) {
$tr = pll_get_post($page->ID, $lang);
if ($tr) {
$link = get_permalink($tr);
if ($link) return $link;
}
}
return $url;
}
add_filter('render_block', function ($content, $block) {
if (empty($block['blockName'])) return $content;
if ($block['blockName'] !== 'core/navigation-link' && $block['blockName'] !== 'core/navigation-submenu') return $content;
if (!function_exists('pll_current_language')) return $content;
$lang = pll_current_language();
$col = fea_menu_lang_col((string) $lang);
if ($col < 0) return $content; // es o desconocido → sin tocar
$label = isset($block['attrs']['label']) ? (string) $block['attrs']['label'] : '';
$url = isset($block['attrs']['url']) ? (string) $block['attrs']['url'] : '';
// 1) etiqueta
if ($label !== '') {
$tr = fea_menu_tr($label, $col);
if ($tr !== null && $tr !== $label) {
$content = str_replace('>' . esc_html($label) . '<', '>' . esc_html($tr) . '<', $content);
}
}
// 2) URL — localizar el href realmente renderizado (robusto frente a discrepancias attr/HTML)
$content = preg_replace_callback('/href=("|\')([^"\']*)\1/i', function ($m) use ($lang) {
$href = html_entity_decode($m[2], ENT_QUOTES);
$loc = fea_menu_localize_url($href, (string) $lang);
return 'href=' . $m[1] . esc_url($loc) . $m[1];
}, $content, 1);
return $content;
}, 10, 2);
@@ -0,0 +1,448 @@
<?php
/**
* fea-pensamientos — Galerías (Pensamientos, jornadas) y pausa aleatoria en artículos.
*
* Las imágenes viven en wp-content/uploads/joomla-galleries/ (copiadas de Joomla
* el 2026-07-08 tras el cutover) — WordPress ya no depende del filesystem de
* Joomla/antiguo.feadulta.com para servirlas.
*/
if (!defined('FEA_JOOMLA_IMAGES_DIR')) {
define('FEA_JOOMLA_IMAGES_DIR', WP_CONTENT_DIR . '/uploads/joomla-galleries');
}
if (!defined('FEA_JOOMLA_IMAGES_URL')) {
define('FEA_JOOMLA_IMAGES_URL', content_url('uploads/joomla-galleries'));
}
if (!defined('FEA_PENS_DIR')) {
define('FEA_PENS_DIR', rtrim(FEA_JOOMLA_IMAGES_DIR, '/') . '/Pensamientos');
}
if (!defined('FEA_PENS_URL')) {
define('FEA_PENS_URL', rtrim(FEA_JOOMLA_IMAGES_URL, '/') . '/Pensamientos');
}
if (!defined('FEA_GALLERY_PER_PAGE')) {
define('FEA_GALLERY_PER_PAGE', 72);
}
if (!defined('FEA_RANDOM_THOUGHT_EXCLUDED_CATS')) {
// Categorías que NO muestran pensamiento aleatorio:
// 1645 Lecturas bíblicas · 28 Evangelios y comentarios (textos del evangelio)
// 20 Presentación colaboradores (fichas de colaboradores)
// 1647 Comentarios al evangelio SÍ muestra pensamiento (decisión Rafa 2026-06-19).
define('FEA_RANDOM_THOUGHT_EXCLUDED_CATS', '1645,28,20');
}
if (!defined('FEA_RANDOM_THOUGHT_EXCLUDED_IDS')) {
// Posts estructurales (páginas disfrazadas de post) que no deben llevar pensamiento.
// 17563 = índice /colaboradores/ (está en «Sin categoría», no lo cubre la cat 20).
define('FEA_RANDOM_THOUGHT_EXCLUDED_IDS', '17563');
}
if (!defined('FEA_GALLERY_MANIFEST')) {
define('FEA_GALLERY_MANIFEST', WP_CONTENT_DIR . '/uploads/fea-gallery-manifest.json');
}
function fea_gallery_safe_dir(string $dir): string {
$dir = trim($dir);
$dir = trim($dir, "/\\ \t\n\r\0\x0B");
return preg_replace('/[^A-Za-z0-9._-]/', '', $dir);
}
function fea_gallery_base_dir(): string {
return rtrim(FEA_JOOMLA_IMAGES_DIR, '/');
}
function fea_gallery_base_url(): string {
return rtrim(FEA_JOOMLA_IMAGES_URL, '/');
}
function fea_gallery_manifest_files(string $dir, string $order = 'desc'): array {
static $manifest = null;
if ($manifest === null) {
$manifest = [];
$path = (string) FEA_GALLERY_MANIFEST;
if (is_readable($path)) {
$decoded = json_decode((string) file_get_contents($path), true);
if (is_array($decoded)) {
$manifest = $decoded;
}
}
}
if (empty($manifest[$dir]) || !is_array($manifest[$dir])) return [];
$files = array_values(array_filter(array_map('strval', $manifest[$dir]), function ($file) {
return preg_match('/\.(jpe?g|png|gif|webp)$/i', $file);
}));
natsort($files);
$files = array_values($files);
if ($order === 'desc') {
$files = array_reverse($files);
}
return $files;
}
function fea_gallery_files(string $dir, string $order = 'desc'): array {
$dir = fea_gallery_safe_dir($dir);
if ($dir === '') return [];
$path = fea_gallery_base_dir() . '/' . $dir;
if (!is_dir($path) || !is_readable($path)) {
return fea_gallery_manifest_files($dir, $order);
}
$mtime = (int) @filemtime($path);
$cache_key = 'fea_gallery_' . md5($path . '|' . $mtime . '|' . $order);
$cached = get_transient($cache_key);
if (is_array($cached)) return $cached;
$files = [];
$entries = @scandir($path);
if (!is_array($entries)) return [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') continue;
if (!preg_match('/\.(jpe?g|png|gif|webp)$/i', $entry)) continue;
if (!is_file($path . '/' . $entry)) continue;
$files[] = $entry;
}
natsort($files);
$files = array_values($files);
if ($order === 'desc') {
$files = array_reverse($files);
}
set_transient($cache_key, $files, 10 * MINUTE_IN_SECONDS);
return $files;
}
function fea_gallery_url(string $dir, string $file): string {
return fea_gallery_base_url() . '/' . rawurlencode(fea_gallery_safe_dir($dir)) . '/' . rawurlencode($file);
}
function fea_gallery_page_param(string $dir): string {
return 'fea_gallery_' . substr(md5($dir), 0, 8);
}
function fea_gallery_render(array $atts = []): string {
$atts = shortcode_atts([
'dir' => 'Pensamientos',
'per_page' => (string) FEA_GALLERY_PER_PAGE,
'order' => 'desc',
], $atts, 'fea_galeria');
$dir = fea_gallery_safe_dir((string) $atts['dir']);
if ($dir === '') return '';
$order = strtolower((string) $atts['order']) === 'asc' ? 'asc' : 'desc';
$files = fea_gallery_files($dir, $order);
if (!$files) {
return '<p class="fea-gallery-empty">Galería no disponible.</p>';
}
$per_page = max(12, min(144, (int) $atts['per_page']));
$total = count($files);
$pages = max(1, (int) ceil($total / $per_page));
$param = fea_gallery_page_param($dir);
$page = isset($_GET[$param]) ? max(1, (int) $_GET[$param]) : 1;
$page = min($page, $pages);
$offset = ($page - 1) * $per_page;
$visible = array_slice($files, $offset, $per_page);
$html = '<div class="fea-gallery" data-fea-gallery="' . esc_attr($dir) . '">';
$html .= '<div class="fea-gallery-grid">';
foreach ($visible as $file) {
$url = fea_gallery_url($dir, $file);
$alt = preg_replace('/\.[^.]+$/', '', $file);
$html .= '<a class="fea-gallery-item" href="' . esc_url($url) . '" data-fea-lightbox="1">';
$html .= '<img src="' . esc_url($url) . '" alt="' . esc_attr($alt) . '" loading="lazy" decoding="async">';
$html .= '</a>';
}
$html .= '</div>';
if ($pages > 1) {
$html .= '<nav class="fea-gallery-pages" aria-label="Paginación de galería">';
if ($page > 1) {
$html .= '<a href="' . esc_url(add_query_arg($param, $page - 1)) . '">Anterior</a>';
}
$html .= '<span>Página ' . esc_html((string) $page) . ' de ' . esc_html((string) $pages) . '</span>';
if ($page < $pages) {
$html .= '<a href="' . esc_url(add_query_arg($param, $page + 1)) . '">Siguiente</a>';
}
$html .= '</nav>';
}
$html .= '</div>';
return $html;
}
add_shortcode('fea_galeria', 'fea_gallery_render');
add_filter('the_content', function ($content) {
if (is_admin() || stripos($content, '{gallery}') === false) return $content;
return preg_replace_callback(
'/\{gallery\}\s*([^{}]+?)\s*\{\/gallery\}/i',
function ($m) {
return fea_gallery_render(['dir' => trim($m[1])]);
},
$content
);
}, 8);
function fea_random_thought_html(): string {
$files = fea_gallery_files('Pensamientos', 'desc');
if (!$files) return '';
$file = $files[array_rand($files)];
$url = fea_gallery_url('Pensamientos', $file);
return '<aside class="fea-random-thought" aria-label="Una pausa">'
. '<div class="fea-random-thought-title"><span></span><strong>Una pausa para el alma</strong><span></span></div>'
. '<a href="' . esc_url($url) . '" data-fea-lightbox="1">'
. '<img src="' . esc_url($url) . '" alt="Pensamiento aleatorio" loading="lazy" decoding="async">'
. '</a></aside>';
}
function fea_random_thought_excluded(): bool {
if (!is_singular('post')) return true;
$post_id = get_the_ID();
$excluded_ids = array_filter(array_map('intval', explode(',', (string) FEA_RANDOM_THOUGHT_EXCLUDED_IDS)));
if ($post_id && in_array((int) $post_id, $excluded_ids, true)) return true;
if ($post_id) {
$raw = (string) get_post_field('post_content', $post_id);
if (stripos($raw, '{gallery}') !== false || stripos($raw, '[fea_galeria') !== false) {
return true;
}
}
$ids = array_filter(array_map('intval', explode(',', (string) FEA_RANDOM_THOUGHT_EXCLUDED_CATS)));
foreach ($ids as $id) {
if ($id > 0 && has_category($id)) return true;
}
return false;
}
add_shortcode('fea_reflexion_aleatoria', function () {
return fea_random_thought_html();
});
add_filter('the_content', function ($content) {
if (is_admin() || !is_main_query() || !in_the_loop() || fea_random_thought_excluded()) {
return $content;
}
if (strpos($content, 'fea-random-thought') !== false) return $content;
$thought = fea_random_thought_html();
return $thought ? $content . $thought : $content;
}, 18);
function fea_pensamientos_assets_needed(): bool {
if (!is_singular()) return false;
$post_id = get_queried_object_id();
$raw = $post_id ? (string) get_post_field('post_content', $post_id) : '';
if (stripos($raw, '{gallery}') !== false || stripos($raw, '[fea_galeria') !== false || stripos($raw, '[fea_reflexion_aleatoria') !== false) {
return true;
}
return !fea_random_thought_excluded();
}
add_action('wp_head', function () {
if (!fea_pensamientos_assets_needed()) return;
?>
<style>
.fea-gallery {
width: min(1180px, 100%);
max-width: none;
margin: 1.5rem auto 2rem;
}
.fea-gallery .fea-gallery-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
gap: 0.65rem !important;
}
.fea-gallery-item {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 275 / 160;
overflow: hidden;
border: 1px solid rgba(0,0,0,0.08);
border-radius: 3px;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.07);
}
.fea-gallery-item img {
width: 98.5%;
height: 96.5%;
object-fit: contain;
display: block;
transition: transform 0.16s ease;
}
.fea-gallery-item:hover img { transform: scale(1.02); }
.fea-gallery-pages {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.25rem;
font-size: 0.95rem;
}
.fea-gallery-pages a {
padding: 0.45rem 0.75rem;
border: 1px solid currentColor;
border-radius: 6px;
text-decoration: none;
}
.fea-random-thought {
margin: 2.4rem auto 1.4rem;
max-width: 720px;
text-align: center;
}
.fea-random-thought-title {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
color: #7f1d1d;
font-size: 0.95rem;
}
.fea-random-thought-title span {
height: 1px;
background: currentColor;
opacity: 0.28;
}
.fea-random-thought a { display: inline-block; max-width: min(100%, 560px); }
.fea-random-thought img {
display: block;
width: 100%;
height: auto;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0,0,0,0.14);
}
.fea-lightbox {
position: fixed;
inset: 0;
z-index: 99999;
display: none;
align-items: center;
justify-content: center;
padding: 4rem 5rem;
background: rgba(0,0,0,0.86);
}
.fea-lightbox.is-open { display: flex; }
.fea-lightbox-frame {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.fea-lightbox-frame img {
max-width: min(100%, 1100px);
max-height: calc(100vh - 8rem);
width: auto;
height: auto;
border-radius: 4px;
background: #fff;
box-shadow: 0 18px 60px rgba(0,0,0,0.45);
}
.fea-lightbox button {
position: absolute;
border: 0;
border-radius: 999px;
background: rgba(255,255,255,0.92);
color: #111;
line-height: 1;
cursor: pointer;
}
.fea-lightbox-close {
top: 0.75rem;
right: 0.75rem;
width: 2.5rem;
height: 2.5rem;
font-size: 1.6rem;
}
.fea-lightbox-prev,
.fea-lightbox-next {
top: 50%;
width: 3rem;
height: 3rem;
font-size: 2rem;
transform: translateY(-50%);
}
.fea-lightbox-prev { left: 1rem; }
.fea-lightbox-next { right: 1rem; }
@media (max-width: 700px) {
.fea-gallery { width: min(100%, calc(100vw - 1rem)); }
.fea-gallery .fea-gallery-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
.fea-lightbox { padding: 3.5rem 1rem; }
.fea-lightbox-prev,
.fea-lightbox-next {
top: auto;
bottom: 0.75rem;
transform: none;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
var links = Array.prototype.slice.call(document.querySelectorAll('[data-fea-lightbox="1"]'));
if (!links.length) return;
var box = document.createElement('div');
box.className = 'fea-lightbox';
box.innerHTML = '<button type="button" class="fea-lightbox-close" aria-label="Cerrar">&times;</button><button type="button" class="fea-lightbox-prev" aria-label="Anterior">&#8249;</button><div class="fea-lightbox-frame"><img alt=""></div><button type="button" class="fea-lightbox-next" aria-label="Siguiente">&#8250;</button>';
document.body.appendChild(box);
var img = box.querySelector('img');
var closeButton = box.querySelector('.fea-lightbox-close');
var prevButton = box.querySelector('.fea-lightbox-prev');
var nextButton = box.querySelector('.fea-lightbox-next');
var index = 0;
var show = function (nextIndex) {
index = (nextIndex + links.length) % links.length;
img.src = links[index].href;
img.alt = links[index].querySelector('img') ? links[index].querySelector('img').alt : '';
box.classList.add('is-open');
};
var close = function () {
box.classList.remove('is-open');
img.removeAttribute('src');
};
box.addEventListener('click', function (event) {
if (event.target === box) close();
});
closeButton.addEventListener('click', close);
prevButton.addEventListener('click', function () { show(index - 1); });
nextButton.addEventListener('click', function () { show(index + 1); });
document.addEventListener('keydown', function (event) {
if (!box.classList.contains('is-open')) return;
if (event.key === 'Escape') close();
if (event.key === 'ArrowLeft') show(index - 1);
if (event.key === 'ArrowRight') show(index + 1);
});
links.forEach(function (link, linkIndex) {
link.addEventListener('click', function (event) {
event.preventDefault();
show(linkIndex);
});
});
});
</script>
<?php
}, 30);
@@ -0,0 +1,277 @@
<?php
/**
* fea-recopilatorios — Listados dinámicos auto-actualizables (issues #96-#118).
*
* Sustituye las páginas-recopilatorio MANUALES de Joomla (tablas/listas de
* enlaces mantenidas a mano) por un listado generado desde una categoría.
* Así, cada carta/post nuevo que entre en la categoría aparece solo, sin
* copiar y pegar.
*
* Uso:
* [fea_recopilatorio cat="1648"] (por term_id)
* [fea_recopilatorio cat="eucaristia"] (por slug)
* [fea_recopilatorio cat="1648" per_page="150" group="year" order="desc"]
*
* - group="year" (def.): separadores por año. group="none": lista plana.
* - Paginación propia (?recop=N) para no chocar con la paginación del tema.
* - Títulos normalizados con fea_title() si existe (legacy en MAYÚSCULAS).
*/
if (!defined('FEA_RECOP_DEFAULT_PER_PAGE')) {
define('FEA_RECOP_DEFAULT_PER_PAGE', 200);
}
function fea_recop_resolve_term($cat): int {
$cat = trim((string) $cat);
if ($cat === '') return 0;
if (ctype_digit($cat)) return (int) $cat;
$t = get_term_by('slug', $cat, 'category');
return $t ? (int) $t->term_id : 0;
}
function fea_recop_title(string $raw): string {
return function_exists('fea_title') ? fea_title($raw) : $raw;
}
function fea_recop_render(array $atts = []): string {
$atts = shortcode_atts([
'cat' => '',
'per_page' => (string) FEA_RECOP_DEFAULT_PER_PAGE,
'group' => 'year',
'order' => 'desc',
], $atts, 'fea_recopilatorio');
$term_id = fea_recop_resolve_term($atts['cat']);
if (!$term_id) return '<p class="fea-recop-empty">Recopilatorio no disponible.</p>';
$per_page = max(20, min(500, (int) $atts['per_page']));
$order = strtolower($atts['order']) === 'asc' ? 'ASC' : 'DESC';
$paged = isset($_GET['recop']) ? max(1, (int) $_GET['recop']) : 1;
$q = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'cat' => $term_id,
'posts_per_page' => $per_page,
'paged' => $paged,
'orderby' => 'date',
'order' => $order,
'ignore_sticky_posts' => true,
'no_found_rows' => false,
]);
if (!$q->have_posts()) {
wp_reset_postdata();
return '<p class="fea-recop-empty">Todavía no hay entradas en esta sección.</p>';
}
$by_year = ($atts['group'] === 'year');
$html = '<div class="fea-recop">';
$cur_year = null;
$open = false;
while ($q->have_posts()) {
$q->the_post();
if ($by_year) {
$y = get_the_date('Y');
if ($y !== $cur_year) {
if ($open) $html .= '</ul>';
$html .= '<h3 class="fea-recop-year">' . esc_html($y) . '</h3><ul class="fea-recop-list">';
$cur_year = $y; $open = true;
}
} elseif (!$open) {
$html .= '<ul class="fea-recop-list">'; $open = true;
}
$title = fea_recop_title(get_the_title());
$html .= '<li><a href="' . esc_url(get_permalink()) . '">' . esc_html($title) . '</a>'
. ' <span class="fea-recop-date">' . esc_html(get_the_date('j M Y')) . '</span></li>';
}
if ($open) $html .= '</ul>';
wp_reset_postdata();
// Paginación propia
$total_pages = (int) $q->max_num_pages;
if ($total_pages > 1) {
$html .= '<nav class="fea-recop-pages" aria-label="Paginación del recopilatorio">';
if ($paged > 1) {
$html .= '<a href="' . esc_url(add_query_arg('recop', $paged - 1)) . '">Anterior</a>';
}
$html .= '<span>Página ' . $paged . ' de ' . $total_pages . '</span>';
if ($paged < $total_pages) {
$html .= '<a href="' . esc_url(add_query_arg('recop', $paged + 1)) . '">Siguiente</a>';
}
$html .= '</nav>';
}
$html .= '</div>';
return $html;
}
add_shortcode('fea_recopilatorio', 'fea_recop_render');
// ── [fea_multimedia_indice] — galería visual de multimedia (issue #110) ──────
// Sustituye la página intermedia /multimedia/ (4 enlaces) por la lista directa
// de los artículos de las categorías multimedia, con preview visual (miniatura
// de YouTube o primera imagen del contenido) + extracto, para invitar al clic.
/** Extrae una preview del contenido: ['type'=>'video'|'image'|'none','src'=>url]. */
function fea_mm_preview(string $content): array {
// 1) Vídeo de YouTube embebido → miniatura hqdefault
if (preg_match('~(?:youtube(?:-nocookie)?\.com/(?:embed/|watch\?v=)|youtu\.be/)([A-Za-z0-9_-]{6,})~', $content, $m)) {
return ['type' => 'video', 'src' => 'https://img.youtube.com/vi/' . $m[1] . '/hqdefault.jpg'];
}
// 2) Vimeo → sin thumbnail server-side fiable; marcar como vídeo sin src
if (preg_match('~vimeo\.com/(?:video/)?(\d+)~', $content)) {
return ['type' => 'video', 'src' => ''];
}
// 3) Primera imagen del contenido
if (preg_match('~<img[^>]+src=["\']([^"\']+)["\']~i', $content, $m)) {
return ['type' => 'image', 'src' => $m[1]];
}
return ['type' => 'none', 'src' => ''];
}
function fea_mm_indice_render(array $atts = []): string {
$atts = shortcode_atts([
'cats' => '1649,26', // Multimedia + Índice multimedia
'per_page' => '24',
], $atts, 'fea_multimedia_indice');
$cats = array_filter(array_map('intval', explode(',', $atts['cats'])));
if (!$cats) return '';
$per_page = max(6, min(60, (int) $atts['per_page']));
$paged = isset($_GET['mmpag']) ? max(1, (int) $_GET['mmpag']) : 1;
$q = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
'category__in' => $cats,
'posts_per_page' => $per_page,
'paged' => $paged,
'orderby' => 'date',
'order' => 'DESC',
'ignore_sticky_posts' => true,
]);
if (!$q->have_posts()) {
wp_reset_postdata();
return '<p class="fea-recop-empty">Todavía no hay multimedia disponible.</p>';
}
$html = '<div class="fea-mm-wrap"><div class="fea-mm-grid">';
while ($q->have_posts()) {
$q->the_post();
$content = get_the_content();
$prev = fea_mm_preview($content);
$title = fea_recop_title(get_the_title());
$url = get_permalink();
$excerpt = wp_trim_words(trim(preg_replace('/\s+/', ' ', wp_strip_all_tags($content))), 22, '…');
$thumb = '';
if ($prev['src'] !== '') {
$thumb = '<img class="fea-mm-img" src="' . esc_url($prev['src']) . '" alt="" loading="lazy" '
. 'onerror="this.style.display=\'none\';this.parentNode.classList.add(\'fea-mm-noimg\');">';
}
$cls = 'fea-mm-thumb' . ($prev['src'] === '' ? ' fea-mm-noimg' : '');
$play = $prev['type'] === 'video'
? '<span class="fea-mm-play" aria-hidden="true"></span>' : '';
$html .= '<a class="fea-mm-card" href="' . esc_url($url) . '">'
. '<span class="' . $cls . '">' . $thumb . $play . '</span>'
. '<span class="fea-mm-body">'
. '<span class="fea-mm-title">' . esc_html($title) . '</span>'
. '<span class="fea-mm-date">' . esc_html(get_the_date('j M Y')) . '</span>'
. '<span class="fea-mm-excerpt">' . esc_html($excerpt) . '</span>'
. '</span></a>';
}
$html .= '</div>';
$total_pages = (int) $q->max_num_pages;
wp_reset_postdata();
if ($total_pages > 1) {
$html .= '<nav class="fea-recop-pages" aria-label="Paginación de multimedia">';
if ($paged > 1) $html .= '<a href="' . esc_url(add_query_arg('mmpag', $paged - 1)) . '">Anterior</a>';
$html .= '<span>Página ' . $paged . ' de ' . $total_pages . '</span>';
if ($paged < $total_pages) $html .= '<a href="' . esc_url(add_query_arg('mmpag', $paged + 1)) . '">Siguiente</a>';
$html .= '</nav>';
}
$html .= '</div>'; // .fea-mm-wrap
return $html;
}
add_shortcode('fea_multimedia_indice', 'fea_mm_indice_render');
add_action('wp_head', function () {
if (is_admin()) return;
?>
<style id="fea-recop-css">
.fea-recop { margin: 1.5rem 0; }
.fea-recop-year {
font-family: 'Fraunces', Georgia, serif; font-weight: 600;
color: #8b1a2e; margin: 1.6rem 0 0.6rem; font-size: 1.3rem;
border-bottom: 1px solid #efe7d8; padding-bottom: 0.25rem;
}
.fea-recop-list { list-style: none; margin: 0; padding: 0; }
.fea-recop-list li {
padding: 0.35rem 0; border-bottom: 1px solid #f4eee2;
display: flex; justify-content: space-between; gap: 1rem; align-items: baseline;
}
.fea-recop-list a { text-decoration: none; color: #2a2320; }
.fea-recop-list a:hover { color: #8b1a2e; text-decoration: underline; }
.fea-recop-date { color: #998; font-size: 0.82rem; white-space: nowrap; }
.fea-recop-pages {
display: flex; gap: 1rem; align-items: center; justify-content: center;
margin-top: 1.4rem; font-size: 0.95rem;
}
.fea-recop-pages a {
padding: 0.4rem 0.8rem; border: 1px solid #8b1a2e; border-radius: 6px;
text-decoration: none; color: #8b1a2e;
}
/* Galería multimedia (#110) */
/* wrapper: el padre es entry-content alignfull (ancho completo), así que basta
centrar con margin:auto; max-width:none vence el cap de is-layout-constrained */
.fea-mm-wrap {
width: min(1180px, 100%);
max-width: none;
margin: 1.5rem auto 2rem;
}
.fea-mm-grid {
display: grid; gap: 1.3rem;
grid-template-columns: 1fr;
}
@media (min-width: 520px) { .fea-mm-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 760px) { .fea-mm-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (max-width: 700px) { .fea-mm-wrap { width: min(100%, calc(100vw - 1rem)); } }
.fea-mm-card {
display: flex; flex-direction: column; text-decoration: none;
background: #fff; border: 1px solid #efe7d8; border-radius: 10px;
overflow: hidden; transition: box-shadow .15s, transform .15s;
}
.fea-mm-card:hover { box-shadow: 0 6px 18px rgba(139,26,46,.13); transform: translateY(-2px); }
.fea-mm-thumb {
position: relative; display: block; aspect-ratio: 16/9; background: #f4eee2;
overflow: hidden;
}
.fea-mm-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fea-mm-noimg {
background: linear-gradient(135deg, #8b1a2e 0%, #b34255 100%);
}
.fea-mm-play {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 54px; height: 54px; border-radius: 50%;
background: rgba(0,0,0,.55); pointer-events: none;
}
.fea-mm-play::after {
content: ''; position: absolute; top: 50%; left: 54%; transform: translate(-50%, -50%);
border-style: solid; border-width: 11px 0 11px 18px;
border-color: transparent transparent transparent #fff;
}
.fea-mm-card:hover .fea-mm-play { background: rgba(139,26,46,.85); }
.fea-mm-body { padding: 0.8rem 0.9rem 1rem; display: flex; flex-direction: column; gap: 0.3rem; }
.fea-mm-title {
font-family: 'Fraunces', Georgia, serif; font-weight: 600; color: #2a2320;
font-size: 1.02rem; line-height: 1.25;
}
.fea-mm-card:hover .fea-mm-title { color: #8b1a2e; }
.fea-mm-date { color: #998; font-size: 0.78rem; }
.fea-mm-excerpt { color: #5a534e; font-size: 0.85rem; line-height: 1.4; margin-top: 0.15rem; }
</style>
<?php
}, 26);
+336
View File
@@ -0,0 +1,336 @@
<?php
/**
* Plugin Name: Fe Adulta — Support Campaign
* Description: Landing y banner discreto de apoyo económico para la migración de Fe Adulta.
* Version: 1.0
*/
if (!defined('ABSPATH')) {
exit;
}
const FEA_SUPPORT_TEMPLATE = 'fea-support-campaign.php';
const FEA_SUPPORT_TEMPLATE_LABEL = 'Fe Adulta — Campaña de apoyo';
function fea_support_template_path(): string {
return __DIR__ . '/fea-support-campaign/template.php';
}
function fea_support_is_spanish_context(): bool {
if (!function_exists('pll_current_language')) {
return true;
}
return pll_current_language() === 'es';
}
function fea_support_campaign_page(): ?WP_Post {
static $page = 'unset';
if ($page !== 'unset') {
return $page instanceof WP_Post ? $page : null;
}
$pages = get_posts([
'post_type' => 'page',
'post_status' => ['publish', 'draft', 'private'],
'posts_per_page' => 1,
'meta_key' => '_wp_page_template',
'meta_value' => FEA_SUPPORT_TEMPLATE,
]);
if (!$pages) {
$slug_page = get_page_by_path('apoya-feadulta', OBJECT, 'page');
$page = $slug_page instanceof WP_Post ? $slug_page : null;
return $page;
}
$page = $pages[0];
return $page;
}
function fea_support_campaign_url(): string {
$page = fea_support_campaign_page();
if (!$page || $page->post_status !== 'publish') {
return '';
}
return (string) get_permalink($page);
}
function fea_support_meta(int $page_id, string $key, $default = '') {
$value = get_post_meta($page_id, $key, true);
return ($value === '' || $value === null) ? $default : $value;
}
function fea_support_campaign_data(?int $page_id = null): array {
$page_id = $page_id ?: (fea_support_campaign_page()?->ID ?? 0);
if (!$page_id) {
return [];
}
$goal = (float) fea_support_meta($page_id, 'fea_support_goal', 2000);
$raised = (float) fea_support_meta($page_id, 'fea_support_raised', 0);
$goal = $goal > 0 ? $goal : 2000;
$raised = max(0, $raised);
return [
'page_id' => $page_id,
'eyebrow' => (string) fea_support_meta($page_id, 'fea_support_eyebrow', 'Apoya Fe Adulta'),
'hero_title' => (string) fea_support_meta($page_id, 'fea_support_hero_title', 'Ayúdanos a sostener la nueva web de Fe Adulta'),
'hero_intro' => (string) fea_support_meta($page_id, 'fea_support_hero_intro', 'La migración de Fe Adulta ha requerido meses de trabajo y un coste aproximado de 2000€. Si puedes colaborar, por pequeña que sea la aportación, nos ayudas a sostener este esfuerzo compartido.'),
'progress_note' => (string) fea_support_meta($page_id, 'fea_support_progress_note', 'Objetivo aproximado para cubrir el trabajo técnico y la migración.'),
'banner_title' => (string) fea_support_meta($page_id, 'fea_support_banner_title', 'Estamos sosteniendo la nueva web entre todos'),
'banner_text' => (string) fea_support_meta($page_id, 'fea_support_banner_text', 'La migración ha supuesto meses de trabajo y unos 2000€ de coste. Si puedes colaborar, nos ayudas a cuidar Fe Adulta.'),
'goal' => $goal,
'raised' => $raised,
'stripe_url' => (string) fea_support_meta($page_id, 'fea_support_stripe_url', ''),
'cajamar_url' => (string) fea_support_meta($page_id, 'fea_support_cajamar_url', ''),
'paypal_url' => (string) fea_support_meta($page_id, 'fea_support_paypal_url', ''),
];
}
function fea_support_amount(float $amount): string {
if (floor($amount) === $amount) {
return number_format_i18n($amount, 0) . '€';
}
return number_format_i18n($amount, 2) . '€';
}
function fea_support_progress_percent(array $data): float {
$goal = (float) ($data['goal'] ?? 0);
$raised = (float) ($data['raised'] ?? 0);
if ($goal <= 0) {
return 0;
}
return max(0, min(100, ($raised / $goal) * 100));
}
function fea_support_buttons_html(array $data, string $context = 'page'): string {
$buttons = [
'stripe_url' => ['label' => 'Donar con Stripe', 'class' => 'is-primary'],
'cajamar_url' => ['label' => 'Donar con Cajamar', 'class' => 'is-secondary'],
'paypal_url' => ['label' => 'Donar con PayPal', 'class' => 'is-secondary'],
];
$html = '<div class="fea-support-actions fea-support-actions--' . esc_attr($context) . '">';
foreach ($buttons as $key => $config) {
if (empty($data[$key])) {
continue;
}
$html .= '<a class="fea-support-button ' . esc_attr($config['class']) . '" href="'
. esc_url($data[$key]) . '" target="_blank" rel="noopener">'
. esc_html($config['label']) . '</a>';
}
$html .= '</div>';
return $html;
}
function fea_support_progress_html(array $data, string $context = 'page'): string {
$percent = fea_support_progress_percent($data);
$summary = fea_support_amount((float) $data['raised']) . ' de ' . fea_support_amount((float) $data['goal']);
$html = '<div class="fea-support-progress fea-support-progress--' . esc_attr($context) . '">';
$html .= '<div class="fea-support-progress__numbers">';
$html .= '<strong>' . esc_html($summary) . '</strong>';
$html .= '<span>' . esc_html(number_format_i18n($percent, 0)) . '%</span>';
$html .= '</div>';
$html .= '<div class="fea-support-progress__track" aria-hidden="true">';
$html .= '<span class="fea-support-progress__fill" style="width:' . esc_attr(number_format($percent, 2, '.', '')) . '%"></span>';
$html .= '</div>';
if (!empty($data['progress_note'])) {
$html .= '<p class="fea-support-progress__note">' . esc_html($data['progress_note']) . '</p>';
}
$html .= '</div>';
return $html;
}
function fea_support_banner_html(): string {
$page = fea_support_campaign_page();
if (!$page || $page->post_status !== 'publish') {
return '';
}
$data = fea_support_campaign_data($page->ID);
$url = get_permalink($page);
$html = '<section class="fea-support-banner" aria-label="Campaña de apoyo económico">';
$html .= '<div class="fea-support-banner__copy">';
$html .= '<span class="fea-support-banner__eyebrow">' . esc_html($data['eyebrow']) . '</span>';
$html .= '<h2 class="fea-support-banner__title">' . esc_html($data['banner_title']) . '</h2>';
$html .= '<p class="fea-support-banner__text">' . esc_html($data['banner_text']) . '</p>';
$html .= '</div>';
$html .= '<div class="fea-support-banner__side">';
$html .= fea_support_progress_html($data, 'banner');
$html .= '<div class="fea-support-banner__links">';
$html .= '<a class="fea-support-button is-primary" href="' . esc_url($url) . '">Ver la campaña</a>';
$html .= '</div>';
$html .= '</div>';
$html .= '</section>';
return $html;
}
add_filter('theme_page_templates', function(array $templates, WP_Theme $theme, ?WP_Post $post, string $post_type): array {
if ($post_type === 'page') {
$templates[FEA_SUPPORT_TEMPLATE] = FEA_SUPPORT_TEMPLATE_LABEL;
}
return $templates;
}, 10, 4);
add_filter('template_include', function(string $template): string {
if (!is_page()) {
return $template;
}
$page = get_queried_object();
if (!$page instanceof WP_Post) {
return $template;
}
if (get_page_template_slug($page) !== FEA_SUPPORT_TEMPLATE) {
return $template;
}
return fea_support_template_path();
});
add_action('acf/init', function() {
if (!function_exists('acf_add_local_field_group')) {
return;
}
acf_add_local_field_group([
'key' => 'group_fea_support_campaign',
'title' => 'Campaña de apoyo económico',
'fields' => [
[
'key' => 'field_fea_support_goal',
'label' => 'Objetivo económico',
'name' => 'fea_support_goal',
'type' => 'number',
'instructions' => 'Importe objetivo de la campaña.',
'default_value' => 2000,
'min' => 1,
'step' => 1,
],
[
'key' => 'field_fea_support_raised',
'label' => 'Importe recaudado',
'name' => 'fea_support_raised',
'type' => 'number',
'instructions' => 'Cantidad actual recaudada.',
'default_value' => 0,
'min' => 0,
'step' => 0.01,
],
[
'key' => 'field_fea_support_eyebrow',
'label' => 'Antetítulo',
'name' => 'fea_support_eyebrow',
'type' => 'text',
'default_value' => 'Apoya Fe Adulta',
],
[
'key' => 'field_fea_support_hero_title',
'label' => 'Título principal',
'name' => 'fea_support_hero_title',
'type' => 'text',
'default_value' => 'Ayúdanos a sostener la nueva web de Fe Adulta',
],
[
'key' => 'field_fea_support_hero_intro',
'label' => 'Texto principal',
'name' => 'fea_support_hero_intro',
'type' => 'textarea',
'rows' => 4,
'new_lines' => 'br',
'default_value' => 'La migración de Fe Adulta ha requerido meses de trabajo y un coste aproximado de 2000€. Si puedes colaborar, por pequeña que sea la aportación, nos ayudas a sostener este esfuerzo compartido.',
],
[
'key' => 'field_fea_support_progress_note',
'label' => 'Nota bajo la barra',
'name' => 'fea_support_progress_note',
'type' => 'text',
'default_value' => 'Objetivo aproximado para cubrir el trabajo técnico y la migración.',
],
[
'key' => 'field_fea_support_banner_title',
'label' => 'Título del banner de portada',
'name' => 'fea_support_banner_title',
'type' => 'text',
'default_value' => 'Estamos sosteniendo la nueva web entre todos',
],
[
'key' => 'field_fea_support_banner_text',
'label' => 'Texto del banner de portada',
'name' => 'fea_support_banner_text',
'type' => 'textarea',
'rows' => 3,
'new_lines' => 'br',
'default_value' => 'La migración ha supuesto meses de trabajo y unos 2000€ de coste. Si puedes colaborar, nos ayudas a cuidar Fe Adulta.',
],
[
'key' => 'field_fea_support_stripe_url',
'label' => 'URL Stripe',
'name' => 'fea_support_stripe_url',
'type' => 'url',
],
[
'key' => 'field_fea_support_cajamar_url',
'label' => 'URL TPV Cajamar',
'name' => 'fea_support_cajamar_url',
'type' => 'url',
],
[
'key' => 'field_fea_support_paypal_url',
'label' => 'URL PayPal',
'name' => 'fea_support_paypal_url',
'type' => 'url',
],
],
'location' => [[
['param' => 'page_template', 'operator' => '==', 'value' => FEA_SUPPORT_TEMPLATE],
]],
'position' => 'normal',
'style' => 'default',
'label_placement' => 'top',
]);
});
add_filter('the_content', function(string $content): string {
// DESACTIVADO temporalmente: la campaña de apoyo (Codex) aún no está lista
// (barra 0€/2.000€, sin enlaces de donación). No mostrar el banner en portada.
// Reactivar quitando este return cuando la campaña esté terminada.
return $content;
if (is_admin() || !is_main_query() || !in_the_loop()) {
return $content;
}
if (!function_exists('fea_is_front_page') || !fea_is_front_page()) {
return $content;
}
if (!fea_support_is_spanish_context()) {
return $content;
}
$banner = fea_support_banner_html();
if (!$banner) {
return $content;
}
return $content . $banner;
}, 40);
add_shortcode('fea_support_campaign_banner', function() {
return fea_support_banner_html();
});
@@ -0,0 +1,244 @@
<?php
if (!defined('ABSPATH')) {
exit;
}
get_header();
the_post();
$data = fea_support_campaign_data(get_the_ID());
?>
<style>
.fea-support-page {
--fea-support-burgundy: #8b1a2e;
--fea-support-ink: #2a2320;
--fea-support-warm: #f5efe7;
--fea-support-line: #e6d8c5;
--fea-support-card: #fffdf9;
max-width: 1180px;
margin: 0 auto;
padding: 2.5rem 1.25rem 4.5rem;
color: var(--fea-support-ink);
}
.fea-support-hero {
background: linear-gradient(180deg, #f3ece3 0%, #fbf7f1 100%);
border: 1px solid var(--fea-support-line);
border-radius: 24px;
padding: 2rem;
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.8fr);
gap: 2rem;
margin-bottom: 2.5rem;
}
.fea-support-hero__eyebrow,
.fea-support-banner__eyebrow {
display: inline-block;
margin-bottom: 0.8rem;
color: var(--fea-support-burgundy);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.fea-support-hero__title {
font-family: "Fraunces", Georgia, serif;
font-size: clamp(2rem, 4vw, 3.35rem);
line-height: 1.05;
margin: 0 0 1rem;
}
.fea-support-hero__intro {
margin: 0;
font-size: 1.03rem;
line-height: 1.7;
max-width: 58ch;
}
.fea-support-card {
background: var(--fea-support-card);
border: 1px solid var(--fea-support-line);
border-radius: 18px;
padding: 1.25rem;
box-shadow: 0 18px 40px -34px rgba(42, 35, 32, 0.55);
}
.fea-support-card__title {
font-size: 0.95rem;
margin: 0 0 1rem;
color: #6f655c;
}
.fea-support-progress {
margin-bottom: 1rem;
}
.fea-support-progress__numbers {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
margin-bottom: 0.55rem;
}
.fea-support-progress__numbers strong {
font-size: 1.35rem;
line-height: 1.1;
}
.fea-support-progress__numbers span {
font-size: 0.92rem;
color: #6f655c;
font-weight: 600;
}
.fea-support-progress__track {
width: 100%;
height: 14px;
border-radius: 999px;
background: #eadfce;
overflow: hidden;
}
.fea-support-progress__fill {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #8b1a2e, #c4884b);
}
.fea-support-progress__note {
margin: 0.65rem 0 0;
font-size: 0.88rem;
line-height: 1.5;
color: #6f655c;
}
.fea-support-actions {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
}
.fea-support-button {
display: inline-flex;
justify-content: center;
align-items: center;
min-height: 44px;
padding: 0.75rem 1rem;
border-radius: 999px;
text-decoration: none;
font-weight: 700;
font-size: 0.94rem;
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.fea-support-button:hover {
transform: translateY(-1px);
box-shadow: 0 14px 24px -20px rgba(42, 35, 32, 0.55);
}
.fea-support-button.is-primary {
background: var(--fea-support-burgundy);
color: #fff;
}
.fea-support-button.is-secondary {
background: #fff;
color: var(--fea-support-ink);
border: 1px solid var(--fea-support-line);
}
.fea-support-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 2rem;
align-items: start;
}
.fea-support-content {
min-width: 0;
}
.fea-support-content > *:first-child {
margin-top: 0;
}
.fea-support-content h2,
.fea-support-content h3 {
font-family: "Fraunces", Georgia, serif;
color: var(--fea-support-ink);
}
.fea-support-content h2 {
margin-top: 2rem;
font-size: clamp(1.5rem, 2.4vw, 2rem);
}
.fea-support-content h3 {
font-size: 1.2rem;
}
.fea-support-content p,
.fea-support-content li {
line-height: 1.75;
}
.fea-support-sidebar {
position: sticky;
top: 2rem;
}
.fea-support-sidebar__small {
margin: 1rem 0 0;
font-size: 0.88rem;
line-height: 1.55;
color: #6f655c;
}
.fea-support-banner {
max-width: 1180px;
margin: 2rem auto 0;
padding: 1.4rem 1.5rem;
border-radius: 20px;
border: 1px solid var(--fea-support-line);
background: linear-gradient(180deg, #f8f3eb 0%, #fffdf9 100%);
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(260px, 0.85fr);
gap: 1.5rem;
align-items: center;
}
.fea-support-banner__title {
font-family: "Fraunces", Georgia, serif;
font-size: clamp(1.4rem, 2.6vw, 2rem);
margin: 0 0 0.45rem;
}
.fea-support-banner__text {
margin: 0;
line-height: 1.65;
}
.fea-support-banner__links {
margin-top: 0.85rem;
}
@media (max-width: 920px) {
.fea-support-hero,
.fea-support-layout,
.fea-support-banner {
grid-template-columns: 1fr;
}
.fea-support-sidebar {
position: static;
}
}
</style>
<main id="wp--skip-link--target" class="fea-support-page">
<section class="fea-support-hero">
<div class="fea-support-hero__copy">
<span class="fea-support-hero__eyebrow"><?php echo esc_html($data['eyebrow'] ?? 'Apoya Fe Adulta'); ?></span>
<h1 class="fea-support-hero__title"><?php echo esc_html($data['hero_title'] ?? get_the_title()); ?></h1>
<p class="fea-support-hero__intro"><?php echo esc_html($data['hero_intro'] ?? ''); ?></p>
</div>
<aside class="fea-support-card" aria-label="Estado de la campaña">
<p class="fea-support-card__title">Objetivo de la campaña</p>
<?php echo fea_support_progress_html($data, 'page'); ?>
<?php echo fea_support_buttons_html($data, 'page'); ?>
</aside>
</section>
<div class="fea-support-layout">
<div class="fea-support-content">
<?php the_content(); ?>
</div>
<aside class="fea-support-sidebar">
<div class="fea-support-card">
<p class="fea-support-card__title">Colaborar ahora</p>
<?php echo fea_support_progress_html($data, 'sidebar'); ?>
<?php echo fea_support_buttons_html($data, 'sidebar'); ?>
<p class="fea-support-sidebar__small">
Si compartes esta página con otras personas de la comunidad, también nos ayudas a acercarnos al objetivo.
</p>
</div>
</aside>
</div>
</main>
<?php
get_footer();
+101
View File
@@ -0,0 +1,101 @@
<?php
/**
* fea-ui — Ajustes estéticos globales del front (issue #95).
*
* 1. Foco/click: elimina el recuadro gris/negro por defecto al hacer click,
* mantiene foco visible y de marca SOLO con teclado (:focus-visible).
* 2. Menú: tipografía editorial Fraunces (coherente con los títulos),
* línea carmesí en hover/ítem activo, sin subrayado.
*
* CSS global (el menú y los enlaces están en todo el sitio), no solo en single.
*/
if (!defined('FEA_UI_CRIMSON')) {
define('FEA_UI_CRIMSON', '#8b1a2e'); // carmesí de marca
}
if (!defined('FEA_UI_INK')) {
define('FEA_UI_INK', '#2a2320'); // texto de marca
}
add_action('wp_head', function () {
if (is_admin()) return;
$crimson = FEA_UI_CRIMSON;
$ink = FEA_UI_INK;
?>
<style id="fea-ui">
/* ── 1. Foco / click ───────────────────────────────────────────── */
a, button, summary, [role="button"] { -webkit-tap-highlight-color: transparent; }
/* Sin outline al hacer click con ratón/dedo… */
a:focus:not(:focus-visible),
button:focus:not(:focus-visible) { outline: none; }
/* …pero foco visible y de marca al navegar con teclado (accesibilidad) */
a:focus-visible,
button:focus-visible {
outline: 2px solid <?php echo $crimson; ?>;
outline-offset: 2px;
border-radius: 3px;
}
/* El core agranda el outline del menú (outline-offset:4px) → lo reducimos */
.wp-block-navigation .wp-block-navigation-item .wp-block-navigation-item__content {
outline-offset: 2px;
}
/* ── 2. Menú — Opción C · Sans limpia minimalista (issue #95) ──── */
.wp-block-navigation .wp-block-navigation-item__content {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-weight: 500;
color: <?php echo $ink; ?>;
text-decoration: none;
position: relative;
}
/* Nunca subrayado (el core lo pone en hover) */
.wp-block-navigation a.wp-block-navigation-item__content:hover,
.wp-block-navigation a.wp-block-navigation-item__content:focus {
text-decoration: none;
}
/* Línea carmesí inferior, animada, en hover y en el ítem activo */
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
> .wp-block-navigation-item__content::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: -3px;
height: 2px;
background: <?php echo $crimson; ?>;
transform: scaleX(0);
transform-origin: left center;
transition: transform 0.18s ease;
}
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
> .wp-block-navigation-item__content:hover::after,
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
> .wp-block-navigation-item__content[aria-current]::after {
transform: scaleX(1);
}
.wp-block-navigation > .wp-block-navigation__container > .wp-block-navigation-item
> .wp-block-navigation-item__content[aria-current] {
color: <?php echo $crimson; ?>;
}
/* Submenús: sin línea inferior; hover marca el texto en carmesí */
.wp-block-navigation__submenu-container .wp-block-navigation-item__content::after {
display: none;
}
.wp-block-navigation__submenu-container .wp-block-navigation-item__content:hover,
.wp-block-navigation__submenu-container .wp-block-navigation-item__content[aria-current] {
color: <?php echo $crimson; ?>;
}
/* ── 3. Fondo cálido en artículos (issue #78 feedback "demasiado blanca") ── */
body.single-post {
background-color: #f5f0eb !important;
--wp--preset--color--base: #f5f0eb;
}
</style>
<?php
}, 25);