/** * YakeddGraph SEO Repair Engine * * Must-Use Plugin — se carga automáticamente antes que temas y plugins. * No requiere activación. * * ▸ INSTALAR: subir este archivo a wp-content/mu-plugins/yakeddgraph-seo-repair.php * * Errores Semrush / Screaming Frog que corrige: * §1 Cabeceras HTTP de seguridad (X-Frame, nosniff, HSTS, Referrer-Policy) * §2 Múltiples H1 en the_content → normaliza a H2 * §3 Jerarquía de encabezados saltados (H3 sin H2, etc.) * §4 Links externos sin rel="noopener noreferrer" * §5 Hreflang: entradas múltiples · falta x-default · sin canonical * back-links noindex · regionales incoherentes (es-es → es) * §5a Paginación: URL de paginación múltiple * TranslatePress genera rel=prev/next por cada idioma; este módulo * deduplica y conserva solo el del idioma activo. * §6 Imágenes sin atributo alt * §7 Imágenes sin atributos width / height * §8 rel="prev" / rel="next" ausentes en archivos y blog paginados * §9 Canonicals: canonical no indexable * Cuando Yoast tiene manual un canonical que apunta a una página noindex, * el filtro lo corrige devolviendo el permalink propio del post. * §10 Códigos 4xx: redirecciones 301 automáticas * /product-2/{slug} → /producto/{slug} (base WC obsoleta) * /product/{slug} → /producto/{slug} (base WC inglés sin prefijo TP) * /en/product-2/{slug} → /en/product/{slug} * Fallback: slug → permalink canónico del product en WooCommerce * §11 Imágenes: texto ALT automático desde título del adjunto * Auto-genera alt descriptivo para imágenes con alt="" vacío. * §12 URLs con espacios * A) Prevención en nuevas subidas (sanitize_file_name) * B) Normalización %20 → - en HTML del contenido * C) Renombrado automático de archivos con espacio + redirect 301 * * Compatible con: Yoast SEO v14+, TranslatePress 2.x-3.x, WooCommerce, * Elementor, LiteSpeed Cache. * * @package YakeddGraph * @version 1.2.3 */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Señal para que el tema (inc/seo-technical.php) no ejecute sus hooks * cuando este MU Plugin ya está activo, evitando doble ejecución. */ define( 'YKG_SEO_MU_ACTIVE', true ); // Marca de verificación para asegurar que Screaming Frog no lee caché add_action('wp_footer', function() { echo "\n" . '' . "\n"; }, 9999); /* ════════════════════════════════════════════════════════════════ § 1 CABECERAS DE SEGURIDAD HTTP ════════════════════════════════════════════════════════════════ */ function ykg_security_headers() { if ( headers_sent() ) { return; } header( 'X-Frame-Options: SAMEORIGIN' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Referrer-Policy: strict-origin-when-cross-origin' ); if ( is_ssl() ) { header( 'Strict-Transport-Security: max-age=31536000; includeSubDomains' ); } } add_action( 'send_headers', 'ykg_security_headers', 1 ); /* ════════════════════════════════════════════════════════════════ § 2 NORMALIZACIÓN H1 EN THE_CONTENT Las plantillas del tema ya incluyen su propio

fuera del contenido del editor. Este filtro convierte cualquier

interno a

garantizando un único H1 por página. ════════════════════════════════════════════════════════════════ */ function ykg_normalize_content_h1( $content ) { if ( ! in_the_loop() || ! is_main_query() || ! is_singular() ) { return $content; } $content = preg_replace( '~]*)>~i', '', $content ); $content = preg_replace( '~

~i', '', $content ); return $content; } add_filter( 'the_content', 'ykg_normalize_content_h1', 20 ); /* ════════════════════════════════════════════════════════════════ § 3 JERARQUÍA DE ENCABEZADOS Normaliza niveles saltados (H4 después de H2, etc.) dentro de the_content asumiendo H1 presente fuera del contenido (en plantilla). ════════════════════════════════════════════════════════════════ */ function ykg_fix_heading_sequence( $content ) { if ( ! in_the_loop() || ! is_main_query() || ! is_singular() ) { return $content; } $prev = 1; return preg_replace_callback( '~<(h[1-6])(\b[^>]*)>(.*?)~is', static function ( $m ) use ( &$prev ) { $lvl = (int) substr( $m[1], 1 ); if ( $lvl > $prev + 1 ) { $lvl = $prev + 1; } $prev = $lvl; return "{$m[3]}"; }, $content ); } add_filter( 'the_content', 'ykg_fix_heading_sequence', 21 ); /* ════════════════════════════════════════════════════════════════ § 4 LINKS EXTERNOS: rel="noopener noreferrer" ════════════════════════════════════════════════════════════════ */ function ykg_fix_external_link_security( $content ) { if ( ! in_the_loop() || ! is_main_query() ) { return $content; } return preg_replace_callback( '~]*)\btarget=["\']_blank["\']([^>]*)>~i', static function ( $m ) { $attrs = $m[1] . ' target="_blank"' . $m[2]; if ( preg_match( '~\brel=["\']([^"\']*)["\']~i', $attrs, $rm ) ) { $vals = preg_split( '/\s+/', trim( $rm[1] ) ); foreach ( [ 'noopener', 'noreferrer' ] as $need ) { if ( ! in_array( $need, $vals, true ) ) { $vals[] = $need; } } $attrs = preg_replace( '~\brel=["\'][^"\']*["\']~i', 'rel="' . esc_attr( implode( ' ', $vals ) ) . '"', $attrs ); } else { $attrs .= ' rel="noopener noreferrer"'; } return ''; }, $content ); } add_filter( 'the_content', 'ykg_fix_external_link_security', 22 ); /* ════════════════════════════════════════════════════════════════ § 5 + §5a HREFLANG Y PAGINACIÓN MÚLTIPLE — BUFFER wp_head Estrategia: captura TODO el output de wp_head (ob_start / ob_get_clean), elimina las etiquetas hreflang y rel=prev/next del HTML capturado, y reconstruye versiones limpias y deduplicadas antes de emitir el head. Resuelve: · Hreflang: Entradas múltiples · Hreflang: Falta x-default · Hreflang: Sin usar canonical · Hreflang: Links de vuelta noindex / no canónicos · Hreflang: Regionales incoherentes (es-es → es, en-us → en) · Paginación: URL de paginación múltiple ← §5a ════════════════════════════════════════════════════════════════ */ $GLOBALS['_ykg_canonical'] = ''; $GLOBALS['_ykg_ob_level'] = -1; /** Captura la URL canónica de Yoast SEO (solo lectura — nunca modifica). */ function ykg_capture_yoast_canonical( $canonical ) { $GLOBALS['_ykg_canonical'] = (string) $canonical; return $canonical; } add_filter( 'wpseo_canonical', 'ykg_capture_yoast_canonical', PHP_INT_MAX ); /** Pre-filtro TranslatePress: vacía el array en páginas noindex/canonicalizadas. */ function ykg_trp_hreflang_pre( $links ) { if ( ! is_array( $links ) ) { return $links; } if ( ykg_is_page_noindex() || ykg_is_page_canonicalized() ) { return []; } foreach ( $links as $lang => $url ) { if ( 'x-default' !== $lang && ykg_url_has_noindex( $url ) ) { unset( $links[ $lang ] ); } } return $links; } add_filter( 'trp_hreflang_links', 'ykg_trp_hreflang_pre', 10 ); /** Buffer START — prioridad 0 (antes que cualquier plugin/tema). */ function ykg_seo_buffer_start() { $GLOBALS['_ykg_ob_level'] = ob_get_level(); ob_start(); } add_action( 'wp_head', 'ykg_seo_buffer_start', 0 ); /** Buffer END — procesa hreflang + rel=prev/next. */ function ykg_seo_buffer_end() { // Cerrar buffers internos abiertos por otros plugins dentro del nuestro. while ( ob_get_level() > $GLOBALS['_ykg_ob_level'] + 1 ) { ob_end_flush(); } $html = ob_get_clean(); if ( false === $html ) { return; } // ── §5a Paginación: deduplicar rel=prev/next ── $pagination_tags = ykg_seo_collect_best_pagination( $html ); $html = ykg_seo_strip_pagination_tags( $html ); // ── §5 Hreflang ── if ( false === stripos( $html, 'hreflang' ) ) { // Retiramos el escape de WordPress temporalmente porque rompía atributos incrustados, // y dejamos que TranslatePress haga su proceso luego o usamos HTML crudo. echo $html . $pagination_tags; return; } if ( ykg_is_page_noindex() || ykg_is_page_canonicalized() ) { echo ykg_strip_hreflang_tags( $html ) . $pagination_tags; return; } $found = ykg_collect_hreflang_from_html( $html ); $html = ykg_strip_hreflang_tags( $html ); // Merge con fuente primaria de TranslatePress para asegurar reciprocidad. // NOTA: TP devuelve claves con locale WP (es_419, en_US) — normalizar a BCP 47. $trp_links = apply_filters( 'trp_hreflang_links', [] ); if ( is_array( $trp_links ) ) { foreach ( $trp_links as $lang => $url ) { $lang = strtolower( str_replace( '_', '-', trim( (string) $lang ) ) ); $url = (string) $url; if ( '' === $lang || '' === $url || isset( $found[ $lang ] ) ) { continue; } $found[ $lang ] = $url; } } // Aplicar canonical al idioma por defecto. // En páginas de idioma alterno de TP (p. ej. /en/), Yoast devuelve el // canonical en español — sobreescribir causaría que 'es' apunte a /en/. $canonical = ykg_get_canonical_url(); if ( ! empty( $canonical ) && ! ykg_is_tp_alternate_page() ) { $trp = get_option( 'trp_settings', [] ); $def_lang = ! empty( $trp['default-language'] ) ? strtolower( str_replace( '_', '-', $trp['default-language'] ) ) : strtolower( str_replace( '_', '-', get_bloginfo( 'language' ) ) ); $def_short = strpos( $def_lang, '-' ) !== false ? strtolower( explode( '-', $def_lang )[0] ) : $def_lang; foreach ( [ $def_lang, $def_short ] as $try ) { if ( isset( $found[ $try ] ) ) { $found[ $try ] = $canonical; break; } } $found['x-default'] = $canonical; } // Garantiza self-hreflang canónica para la URL actual — SOLO en idioma por defecto. // En páginas alternas de TP (/en/, /fr/, etc.) Yoast devuelve el canonical del // idioma base (español). Sobreescribirlo aquí pondría hreflang="en" apuntando a // la URL española → Screaming Frog lo marca como "back-links regionales incoherentes". $current_lang = ykg_get_request_language_code(); if ( ! empty( $current_lang ) && ! empty( $canonical ) && ! ykg_is_tp_alternate_page() ) { $found[ $current_lang ] = $canonical; } // Quitar regionales cuando ya existe el código corto ('es-CL' si hay 'es'). foreach ( array_keys( $found ) as $lang ) { if ( 'x-default' === $lang || ( false === strpos( $lang, '-' ) && false === strpos( $lang, '_' ) ) ) { continue; } $base = strtolower( (string) preg_replace( '/[-_].*$/', '', $lang ) ); if ( isset( $found[ $base ] ) ) { unset( $found[ $lang ] ); } } // Normalizar regionales huérfanos a código base ('es-419' → 'es', 'en-US' → 'en', 'es_419' → 'es'). $normalized = []; foreach ( $found as $lang => $url ) { if ( 'x-default' !== $lang && ( false !== strpos( $lang, '-' ) || false !== strpos( $lang, '_' ) ) ) { $base = strtolower( (string) preg_replace( '/[-_].*$/', '', $lang ) ); if ( ! isset( $normalized[ $base ] ) ) { $normalized[ $base ] = $url; } continue; } $normalized[ $lang ] = $url; } $found = $normalized; // Garantizar que x-default siempre exista. if ( ! isset( $found['x-default'] ) ) { $trp_d = get_option( 'trp_settings', [] ); $dl = ! empty( $trp_d['default-language'] ) ? strtolower( str_replace( '_', '-', $trp_d['default-language'] ) ) : strtolower( str_replace( '_', '-', get_bloginfo( 'language' ) ) ); $dl_s = strpos( $dl, '-' ) !== false ? strtolower( explode( '-', $dl )[0] ) : $dl; foreach ( [ $dl, $dl_s ] as $try ) { if ( isset( $found[ $try ] ) ) { $found['x-default'] = $found[ $try ]; break; } } if ( ! isset( $found['x-default'] ) ) { $found['x-default'] = home_url( '/' ); } } // Canonicalizar y eliminar entradas no indexables. foreach ( $found as $lang => $url ) { if ( 'x-default' === $lang ) { continue; } $canon_url = ykg_get_indexable_canonical_url( $url ); if ( empty( $canon_url ) ) { unset( $found[ $lang ] ); continue; } $found[ $lang ] = $canon_url; } // Reconstruir hreflang limpias manteniendo exactitud absoluta con la URL de solicitud para el código actual. $hreflang_tags = ''; foreach ( $found as $lang => $url ) { $hreflang_tags .= '' . "\n"; } echo $html . $pagination_tags . $hreflang_tags; // phpcs:ignore WordPress.Security.EscapeOutput } add_action( 'wp_head', 'ykg_seo_buffer_end', 99999 ); /* ════════════════════════════════════════════════════════════════ § 6 IMÁGENES SIN ATRIBUTO ALT ════════════════════════════════════════════════════════════════ */ function ykg_fix_missing_img_alt( $content ) { if ( ! in_the_loop() || ! is_main_query() ) { return $content; } return preg_replace( '/]*\balt=)([^>]*)(\/?\s*>)/i', ']*?)>/is', 'ykg_fill_single_img_alt', $html ); $html = preg_replace( '/]*\balt=)([^>]*?)(\/?\s*>)/is', 'Imagen descriptiva]*)>/i', static function ( $m ) { $attrs = $m[1]; $has_width = (bool) preg_match( '/\bwidth=["\']?\d+/i', $attrs ); $has_height = (bool) preg_match( '/\bheight=["\']?\d+/i', $attrs ); if ( $has_width && $has_height ) { return $m[0]; } if ( ! preg_match( '/\bwp-image-(\d+)\b/i', $attrs, $id ) ) { return $m[0]; } $meta = wp_get_attachment_metadata( (int) $id[1] ); if ( empty( $meta['width'] ) || empty( $meta['height'] ) ) { return $m[0]; } if ( ! $has_width ) { $attrs .= ' width="' . (int) $meta['width'] . '"'; } if ( ! $has_height ) { $attrs .= ' height="' . (int) $meta['height'] . '"'; } return ''; }, $content ); } add_filter( 'the_content', 'ykg_fix_missing_img_dimensions', 24 ); /* ════════════════════════════════════════════════════════════════ § 8 PAGINACIÓN rel="prev" / rel="next" (emisión correcta) Solo en el idioma por defecto — en páginas alternas de TranslatePress (/en/, /fr/, etc.) TranslatePress gestiona la paginación correctamente y emitir nosotros generaría URLs en el idioma equivocado. ════════════════════════════════════════════════════════════════ */ function ykg_pagination_head_links() { if ( ykg_is_tp_alternate_page() ) { return; // TP gestiona la paginación en páginas alternas. } global $wp_query; if ( ! ( is_archive() || is_home() || is_front_page() ) ) { return; } $paged = max( 1, (int) get_query_var( 'paged' ) ); $max_page = (int) $wp_query->max_num_pages; if ( $paged > 1 ) { echo '' . "\n"; } if ( $paged < $max_page ) { echo '' . "\n"; } } add_action( 'wp_head', 'ykg_pagination_head_links', 1 ); /** * Screaming Frog exige que URLs de rel=prev/next tambien existan en anchors. * Emitimos un nav minimo y accesible con enlaces clicables para paginacion. */ function ykg_pagination_anchor_fallback() { global $wp_query; if ( ! ( is_archive() || is_home() || is_front_page() ) ) { return; } $max_page = isset( $wp_query->max_num_pages ) ? (int) $wp_query->max_num_pages : 0; if ( $max_page < 2 ) { return; } $paged = max( 1, (int) get_query_var( 'paged' ) ); $prev = $paged > 1 ? get_pagenum_link( $paged - 1 ) : ''; $next = $paged < $max_page ? get_pagenum_link( $paged + 1 ) : ''; if ( empty( $prev ) && empty( $next ) ) { return; } echo ''; } add_action( 'wp_footer', 'ykg_pagination_anchor_fallback', 100 ); /* ════════════════════════════════════════════════════════════════ § 9 CANONICAL NO INDEXABLE Cuando Yoast tiene configurado manualmente un canonical que apunta a una página noindex (ej. productos con slug /product-2/ defectuoso), este filtro lo corrige devolviendo el permalink del post actual. Resuelve en Semrush: "Canonicals: Canonical no indexable". ════════════════════════════════════════════════════════════════ */ add_filter( 'wpseo_canonical', static function ( $canonical ) { if ( empty( $canonical ) ) { return $canonical; } // Si el canonical de cualquier tipo de pagina apunta a noindex, usar URL actual. if ( ykg_url_has_noindex( $canonical ) ) { return ykg_get_current_request_url(); } if ( ! is_singular() ) { return $canonical; } $post_id = get_the_ID(); $permalink = $post_id ? (string) get_permalink( $post_id ) : ''; // Sin permalink resuelto o canonical ya apunta al propio post: sin cambio. if ( empty( $permalink ) || rtrim( $canonical, '/' ) === rtrim( $permalink, '/' ) ) { return $canonical; } // Comprobar si el destino del canonical apunta a un post noindex. $target_id = url_to_postid( $canonical ); if ( $target_id > 0 ) { // Noindex granular (meta de Yoast por post). if ( '1' === (string) get_post_meta( $target_id, '_yoast_wpseo_meta-robots-noindex', true ) ) { return $permalink; } // Noindex global por tipo de post (ajuste en Yoast → Tipos de Contenido). $wpseo = get_option( 'wpseo_titles', [] ); $pt = get_post_type( $target_id ); if ( ! empty( $wpseo[ 'noindex-' . $pt ] ) ) { return $permalink; } } // Fallback singular: si el canonical no resuelve a post pero luce problemático, // forzar permalink propio para evitar "canonical no indexable". if ( 0 === (int) url_to_postid( $canonical ) && ykg_is_suspect_canonical_path( $canonical ) ) { return $permalink; } return $canonical; }, 20 ); /* ════════════════════════════════════════════════════════════════ HELPERS INTERNOS ════════════════════════════════════════════════════════════════ */ /** * Devuelve los slugs de reescritura de taxonomías marcadas como noindex * en Yoast SEO. Caché estática para evitar múltiples lecturas de BD. * * @return string[] */ function ykg_get_noindex_tax_slugs() { static $cache = null; if ( null !== $cache ) { return $cache; } $cache = []; $titles = get_option( 'wpseo_titles', [] ); foreach ( get_taxonomies( [ 'public' => true ], 'objects' ) as $tax ) { if ( empty( $titles[ 'noindex-tax-' . $tax->name ] ) ) { continue; } if ( ! empty( $tax->rewrite['slug'] ) ) { $cache[] = '/' . trim( $tax->rewrite['slug'], '/' ) . '/'; } $cache[] = '/' . trim( $tax->name, '/' ) . '/'; } return $cache; } /** * True si $url apunta a una página noindex. * Cubre posts/páginas individuales, archivos de taxonomía y de fecha. * * @param string $url * @return bool */ function ykg_url_has_noindex( $url ) { $post_id = url_to_postid( $url ); if ( $post_id > 0 ) { if ( '1' === (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true ) ) { return true; } $titles = get_option( 'wpseo_titles', [] ); return ! empty( $titles[ 'noindex-' . get_post_type( $post_id ) ] ); } $url_path = (string) wp_parse_url( $url, PHP_URL_PATH ); foreach ( ykg_get_noindex_tax_slugs() as $slug ) { if ( false !== strpos( $url_path, $slug ) ) { return true; } } $titles = get_option( 'wpseo_titles', [] ); if ( ! empty( $titles['noindex-archive-wpseo'] ) && preg_match( '~/\d{4}(/\d{2}(/\d{2})?)?/?$~', $url_path ) ) { return true; } return false; } /** * True si el REQUEST_URI actual corresponde a una página de idioma alterno * generada por TranslatePress (p. ej. /en/, /fr/). * * @return bool */ function ykg_is_tp_alternate_page() { static $cache = null; if ( null !== $cache ) { return $cache; } $req = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; $trp = get_option( 'trp_settings', [] ); if ( ! empty( $trp['url-slugs'] ) ) { foreach ( $trp['url-slugs'] as $slug ) { $slug = trim( (string) $slug, '/' ); if ( '' !== $slug && 0 === strpos( ltrim( $req, '/' ), $slug . '/' ) ) { return $cache = true; } } } return $cache = false; } /** * True si el canonical de la página difiere de la URL visitada. * Excluye automáticamente páginas alternas de TranslatePress. * * @return bool */ function ykg_is_page_canonicalized() { if ( ykg_is_tp_alternate_page() ) { return false; } $req = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; $actual = rtrim( home_url( $req ), '/' ); if ( function_exists( 'YoastSEO' ) ) { try { $c = YoastSEO()->meta->for_current_page()->canonical; if ( ! empty( $c ) ) { return rtrim( (string) $c, '/' ) !== $actual; } } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement } } if ( ! empty( $GLOBALS['_ykg_canonical'] ) ) { return rtrim( $GLOBALS['_ykg_canonical'], '/' ) !== $actual; } return false; } /** * True si la página actual tiene directiva noindex. * * @return bool */ function ykg_is_page_noindex() { if ( function_exists( 'YoastSEO' ) ) { try { $robots = YoastSEO()->meta->for_current_page()->robots; if ( is_array( $robots ) && ( $robots['index'] ?? '' ) === 'noindex' ) { return true; } } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement } } if ( is_singular() ) { $id = get_the_ID(); if ( $id && '1' === (string) get_post_meta( $id, '_yoast_wpseo_meta-robots-noindex', true ) ) { return true; } } return false; } /** * Devuelve la URL canónica de la página actual. * Prioridad: filtro wpseo_canonical → Yoast API → permalink WP. * * @return string */ function ykg_get_canonical_url() { if ( ! empty( $GLOBALS['_ykg_canonical'] ) ) { return $GLOBALS['_ykg_canonical']; } if ( function_exists( 'YoastSEO' ) ) { try { $c = YoastSEO()->meta->for_current_page()->canonical; if ( ! empty( $c ) ) { return (string) $c; } } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement } } if ( is_singular() ) { return (string) get_permalink(); } if ( is_home() ) { $id = (int) get_option( 'page_for_posts' ); return $id ? (string) get_permalink( $id ) : home_url( '/' ); } if ( is_front_page() ) { return home_url( '/' ); } return (string) get_pagenum_link(); } /** * Elimina de un bloque HTML. * * @param string $html * @return string */ function ykg_strip_hreflang_tags( $html ) { return preg_replace( '~]*\bhreflang=[^>]*/?>[ \t]*\n?~i', '', $html ); } /** * Extrae etiquetas hreflang de un bloque HTML → [ lang => href ]. * Primera ocurrencia de cada lang gana. * * @param string $html * @return array */ function ykg_collect_hreflang_from_html( $html ) { $found = []; if ( ! preg_match_all( '~]*)>~i', $html, $tags ) ) { return $found; } foreach ( $tags[1] as $attrs ) { if ( false === stripos( $attrs, 'hreflang' ) || false === stripos( $attrs, 'alternate' ) ) { continue; } if ( ! preg_match( '~\bhreflang=["\']([^"\']+)["\']~i', $attrs, $lm ) ) { continue; } if ( ! preg_match( '~\bhref=["\']([^"\']+)["\']~i', $attrs, $hm ) ) { continue; } $lang = strtolower( str_replace( '_', '-', trim( $lm[1] ) ) ); if ( ! isset( $found[ $lang ] ) ) { $found[ $lang ] = esc_url_raw( $hm[1] ); } } return $found; } /** * Elimina de un bloque HTML. * * @param string $html * @return string */ function ykg_seo_strip_pagination_tags( $html ) { return preg_replace( '~]*\brel=["\'](?:prev|next)["\'][^>]*/?>[ \t]*\n?~i', '', $html ); } /** * Recoge tags rel=prev/next del HTML capturado y devuelve la versión * deduplicada correcta: * · En idioma por defecto: prefiere URLs sin prefijo de TP (/blog/page/2/). * · En idioma alterno: prefiere URLs con el prefijo activo (/en/blog/page/2/). * Limita siempre a 1 etiqueta por tipo (prev | next). * * @param string $html HTML del buffer de wp_head. * @return string Etiquetas limpias (puede ser cadena vacía si no hay paginación). */ function ykg_seo_collect_best_pagination( $html ) { $all = [ 'prev' => [], 'next' => [] ]; if ( preg_match_all( '~]*)>~i', $html, $tags ) ) { foreach ( $tags[1] as $attrs ) { if ( ! preg_match( '~\brel=["\']([^"\']+)["\']~i', $attrs, $rm ) ) { continue; } $rel = strtolower( trim( $rm[1] ) ); if ( $rel !== 'prev' && $rel !== 'next' ) { continue; } if ( ! preg_match( '~\bhref=["\']([^"\']+)["\']~i', $attrs, $hm ) ) { continue; } $all[ $rel ][] = esc_url_raw( $hm[1] ); } } // Slugs de idioma de TP para identificar URLs alternas. $trp = get_option( 'trp_settings', [] ); $slugs = []; if ( ! empty( $trp['url-slugs'] ) ) { foreach ( $trp['url-slugs'] as $s ) { $s = trim( (string) $s, '/' ); if ( '' !== $s ) { $slugs[] = $s; } } } $is_alt = ykg_is_tp_alternate_page(); $curr_slug = ''; if ( $is_alt ) { $req = ltrim( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/', '/' ); foreach ( $slugs as $s ) { if ( 0 === strpos( $req, $s . '/' ) ) { $curr_slug = $s; break; } } } $result = []; foreach ( [ 'prev', 'next' ] as $type ) { if ( empty( $all[ $type ] ) ) { continue; } $chosen = null; foreach ( $all[ $type ] as $url ) { $path = ltrim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' ); if ( $is_alt && $curr_slug ) { // Idioma alterno → prefiere URL con el prefijo activo. if ( 0 === strpos( $path, $curr_slug . '/' ) ) { $chosen = $url; break; } } else { // Idioma por defecto → prefiere URL sin prefijo de TP. $is_tp = false; foreach ( $slugs as $s ) { if ( 0 === strpos( $path, $s . '/' ) ) { $is_tp = true; break; } } if ( ! $is_tp ) { $chosen = $url; break; } } } $result[ $type ] = $chosen ?? $all[ $type ][0]; // fallback: primera encontrada } $out = ''; foreach ( [ 'prev', 'next' ] as $type ) { if ( ! empty( $result[ $type ] ) ) { $out .= '' . "\n"; } } return $out; } /* ════════════════════════════════════════════════════════════════ §10 REDIRECCIONES 301 — ERRORES 4xx Resuelve: "Códigos de respuesta: Error de cliente interno (4xx)" Casos cubiertos: · /product-2/{slug}/ → /producto/{slug}/ (slug WC obsoleto) · /product/{slug}/ → /producto/{slug}/ (slug WC inglés sin TP) · /en/product-2/{slug}/ → /en/product/{slug}/ (versión inglesa obsoleta) · Fallback: busca el slug como product en WooCommerce y redirige al permalink. ════════════════════════════════════════════════════════════════ */ add_action( 'template_redirect', 'ykg_redirect_4xx', 1 ); function ykg_redirect_4xx() { if ( ! is_404() ) { return; } $req = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; $path = urldecode( (string) wp_parse_url( $req, PHP_URL_PATH ) ); $path = preg_replace( '~//+~', '/', (string) $path ); $path = rtrim( (string) $path, '/' ); if ( '' === $path ) { $path = '/'; } // Reglas robustas para bases obsoletas de WooCommerce. if ( preg_match( '~^/(?:([a-z]{2})/)?product-2/([^/]+)$~i', $path, $m ) ) { $lang = ! empty( $m[1] ) ? strtolower( $m[1] ) : ''; $slug = sanitize_title( (string) $m[2] ); if ( 'en' === $lang ) { wp_safe_redirect( home_url( '/en/product/' . $slug . '/' ), 301 ); exit; } wp_safe_redirect( home_url( '/producto/' . $slug . '/' ), 301 ); exit; } // Variante historica en espanol: /producto-2/{slug}. if ( preg_match( '~^/(?:([a-z]{2})/)?producto-2/([^/]+)$~i', $path, $m ) ) { $lang = ! empty( $m[1] ) ? strtolower( $m[1] ) : ''; $slug = sanitize_title( (string) $m[2] ); if ( 'en' === $lang ) { wp_safe_redirect( home_url( '/en/product/' . $slug . '/' ), 301 ); exit; } wp_safe_redirect( home_url( '/producto/' . $slug . '/' ), 301 ); exit; } if ( preg_match( '~^/product/([^/]+)$~i', $path, $m ) ) { $slug = sanitize_title( (string) $m[1] ); wp_safe_redirect( home_url( '/producto/' . $slug . '/' ), 301 ); exit; } // ── Fallback WooCommerce: buscar el post_name como product ── $slug = trim( (string) basename( rtrim( $path, '/' ) ) ); if ( ! empty( $slug ) ) { $slug = sanitize_title( $slug ); $product = get_page_by_path( $slug, OBJECT, 'product' ); if ( $product && 'publish' === $product->post_status ) { $link = get_permalink( $product ); if ( $link ) { wp_safe_redirect( $link, 301 ); exit; } } } } /* ════════════════════════════════════════════════════════════════ §11 TEXTO ALT AUTOMÁTICO EN IMÁGENES VACÍAS Resuelve: "Imágenes: Falta texto ALT" (imágenes con alt="" — atributo presente pero vacío) Auto-genera texto ALT desde: 1. Título del adjunto en WordPress Media Library 2. Nombre de archivo legible (último recurso) ════════════════════════════════════════════════════════════════ */ /** * En the_content: reemplaza alt="" con el título del adjunto. */ function ykg_autofill_empty_alt( $content ) { if ( ! in_the_loop() || ! is_main_query() ) { return $content; } return preg_replace_callback( '/]*)>/i', 'ykg_fill_single_img_alt', $content ); } add_filter( 'the_content', 'ykg_autofill_empty_alt', 25 ); /** * En wp_get_attachment_image: rellena alt vacío con el título del adjunto. * Se ejecuta con prioridad 15, después del hook de §6 (prioridad 10) * que garantiza que alt existe, y antes de que se renderice el HTML. */ function ykg_fill_attachment_image_alt( $attr, $attachment ) { if ( ! empty( $attr['alt'] ) && '' !== trim( $attr['alt'] ) ) { return $attr; } $title = get_post_field( 'post_title', $attachment->ID ); if ( ! empty( $title ) ) { $attr['alt'] = $title; } return $attr; } add_filter( 'wp_get_attachment_image_attributes', 'ykg_fill_attachment_image_alt', 15, 2 ); /** * Rellena el alt de un único . Usada como callback de preg_replace_callback. * * @param array $m Matches de la regex /]*)>/i * @return string Tag con alt descriptivo. */ function ykg_fill_single_img_alt( $m ) { $attrs = $m[1]; // ¿Ya tiene alt con texto real? No modificar. if ( preg_match( '/\balt=(["\'])(?!\s*\1)/', $attrs ) ) { return $m[0]; } $has_alt = (bool) preg_match( '/\balt=/i', $attrs ); $src_url = ''; if ( preg_match( '/\bsrc=["\']([^"\']+)["\']/', $attrs, $sm ) ) { $src_url = $sm[1]; } // Saneamiento para etiquetas que se cierran solas (/>) $is_closed_tag = false; if ( substr( rtrim( $attrs ), -1 ) === '/' ) { $is_closed_tag = true; $attrs = rtrim( rtrim( $attrs ), '/' ); } $attrs = rtrim( $attrs ); // Determinar ID del adjunto: por clase wp-image-{ID} o por URL de src. $att_id = 0; if ( preg_match( '/\bwp-image-(\d+)\b/', $attrs, $idm ) ) { $att_id = (int) $idm[1]; } elseif ( ! empty( $src_url ) ) { $att_id = (int) attachment_url_to_postid( strtok( $src_url, '?' ) ); } // Construir texto alt. $alt_text = ''; if ( $att_id > 0 ) { $alt_text = (string) get_post_field( 'post_title', $att_id ); } if ( empty( $alt_text ) && ! empty( $src_url ) ) { $name = pathinfo( basename( urldecode( $src_url ) ), PATHINFO_FILENAME ); $alt_text = ucfirst( str_replace( [ '-', '_', '%20' ], ' ', $name ) ); } if ( empty( $alt_text ) ) { $alt_text = 'Imagen descriptiva'; } if ( $has_alt ) { // Reemplazar alt="" por alt="texto" o capturar error de sintaxis alt sin comillas $attrs = preg_replace( '/\balt=([^"\'\s]*)/i', 'alt="' . esc_attr( $alt_text ) . '"', $attrs ); } else { $attrs .= ' alt="' . esc_attr( $alt_text ) . '"'; } return ''; } /* ════════════════════════════════════════════════════════════════ §12 URLs CON ESPACIOS EN NOMBRES DE ARCHIVO Resuelve: "URL: Contiene espacio" Estrategia de 3 niveles: A) Prevención: sanitize_file_name elimina espacios en nuevas subidas. B) Redirect 301: peticiones con %20 en ruta uploads → versión con guión. C) Normalización en HTML: %20 → - en URLs de /wp-content/uploads/. D) Renombrado automático (una sola vez, con bloqueo de opción): encuentra adjuntos con espacio en su ruta → renombra el archivo, sus miniaturas, actualiza la BD y actualiza referencias en contenido. ════════════════════════════════════════════════════════════════ */ // A. Prevención en futuras subidas. add_filter( 'sanitize_file_name', static function ( $filename ) { return str_replace( ' ', '-', $filename ); } ); // B. Redirect 301 para peticiones con %20 en la ruta uploads. add_action( 'template_redirect', 'ykg_redirect_space_urls', 2 ); function ykg_redirect_space_urls() { if ( ! is_404() ) { return; } $req = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; if ( false === strpos( $req, '/wp-content/uploads/' ) ) { return; } if ( false === strpos( $req, '%20' ) && false === strpos( $req, ' ' ) && false === strpos( $req, '+' ) ) { return; } $clean = str_replace( [ '%20', ' ', '+' ], '-', $req ); wp_safe_redirect( home_url( $clean ), 301 ); exit; } // C. Normalización %20 → - en HTML del contenido y widgets. add_filter( 'the_content', 'ykg_fix_space_in_media_urls', 26 ); add_filter( 'widget_text_content', 'ykg_fix_space_in_media_urls', 26 ); function ykg_fix_space_in_media_urls( $content ) { if ( false === strpos( $content, '%20' ) && false === strpos( $content, ' ' ) ) { return $content; } $uploads = wp_get_upload_dir(); $base = isset( $uploads['baseurl'] ) ? preg_quote( rtrim( (string) $uploads['baseurl'], '/' ), '~' ) : ''; if ( empty( $base ) ) { return $content; } return preg_replace_callback( '~(' . $base . '/[^\s\'"<>]+)~i', static function ( $m ) { if ( false === strpos( $m[1], '%20' ) && false === strpos( $m[1], ' ' ) ) { return $m[1]; } return str_replace( [ '%20', ' ' ], '-', $m[1] ); }, $content ); } /** * URL canónica de la solicitud actual (sin query ni fragmento). * * @return string */ function ykg_get_current_request_url() { $req = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; $path = (string) wp_parse_url( $req, PHP_URL_PATH ); $path = preg_replace( '~//+~', '/', $path ); if ( '' === $path ) { $path = '/'; } return home_url( user_trailingslashit( ltrim( $path, '/' ) ) ); } /** * Detecta rutas canónicas sospechosas usadas en errores históricos. * * @param string $url * @return bool */ function ykg_is_suspect_canonical_path( $url ) { $path = (string) wp_parse_url( $url, PHP_URL_PATH ); if ( '' === $path ) { return false; } return (bool) preg_match( '~/(product-2|producto-2|product)/~i', $path ); } /** * Determina codigo de idioma para la URL actual segun TranslatePress. * * @return string */ function ykg_get_request_language_code() { $trp = get_option( 'trp_settings', [] ); $def_lang = ! empty( $trp['default-language'] ) ? strtolower( str_replace( '_', '-', $trp['default-language'] ) ) : strtolower( str_replace( '_', '-', get_bloginfo( 'language' ) ) ); $def_code = strpos( $def_lang, '-' ) !== false ? explode( '-', $def_lang )[0] : $def_lang; $req = ltrim( (string) wp_parse_url( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/', PHP_URL_PATH ), '/' ); if ( ! empty( $trp['url-slugs'] ) && is_array( $trp['url-slugs'] ) ) { foreach ( $trp['url-slugs'] as $slug ) { $slug = strtolower( trim( (string) $slug, '/' ) ); if ( '' !== $slug && ( 0 === strpos( $req, $slug . '/' ) || $req === $slug ) ) { return $slug; } } } return strtolower( $def_code ); } /** * Devuelve canonical indexable de una URL hreflang o cadena vacia. * * @param string $url * @return string */ function ykg_get_indexable_canonical_url( $url ) { $url = esc_url_raw( (string) $url ); if ( empty( $url ) ) { return ''; } if ( ykg_url_has_noindex( $url ) ) { return ''; } // En lugar de usar get_permalink (que elimina el sufijo de idioma de TranslatePress), // confiamos en la URL actual, pero le aplicamos la reescritura de WooCommerce // para corregir los slugs obsoletos. return ykg_rewrite_legacy_internal_urls( $url ); } /** * Reescribe URLs internas historicas que provocan 4xx. * * @param string $html * @return string */ function ykg_rewrite_legacy_internal_urls( $html ) { if ( ! is_string( $html ) || '' === $html ) { return $html; } $home = rtrim( home_url( '/' ), '/' ); $home_q = preg_quote( $home, '~' ); // Absolutas. $html = preg_replace( '~' . $home_q . '/product-2/([^"\'\s<>/?#]+)~i', $home . '/producto/$1', $html ); $html = preg_replace( '~' . $home_q . '/producto-2/([^"\'\s<>/?#]+)~i', $home . '/producto/$1', $html ); $html = preg_replace( '~' . $home_q . '/product/([^"\'\s<>/?#]+)~i', $home . '/producto/$1', $html ); // Relativas. $html = preg_replace( '~(["\'])/product-2/([^"\'\s<>/?#]+)~i', '$1/producto/$2', $html ); $html = preg_replace( '~(["\'])/producto-2/([^"\'\s<>/?#]+)~i', '$1/producto/$2', $html ); $html = preg_replace( '~(["\'])/product/([^"\'\s<>/?#]+)~i', '$1/producto/$2', $html ); return $html; } // D. Renombrado automático de adjuntos con espacio (se ejecuta una sola vez). add_action( 'init', 'ykg_rename_space_attachments', 20 ); function ykg_rename_space_attachments() { // Bloqueo persistente: solo se ejecuta una vez. if ( get_option( 'ykg_space_rename_done' ) ) { return; } // No bloquear peticiones AJAX ni cron. if ( wp_doing_ajax() || wp_doing_cron() ) { return; } $uploads = wp_get_upload_dir(); $base_dir = rtrim( (string) ( $uploads['basedir'] ?? '' ), '/' ); $base_url = rtrim( (string) ( $uploads['baseurl'] ?? '' ), '/' ); if ( empty( $base_dir ) ) { update_option( 'ykg_space_rename_done', 1, false ); return; } global $wpdb; // Buscar adjuntos cuyo _wp_attached_file tenga espacios. $rows = $wpdb->get_results( "SELECT p.ID, p.guid, pm.meta_value AS file_rel FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_wp_attached_file' WHERE p.post_type = 'attachment' AND pm.meta_value LIKE '% %' LIMIT 100" ); if ( empty( $rows ) ) { update_option( 'ykg_space_rename_done', 1, false ); return; } foreach ( $rows as $row ) { $old_rel = $row->file_rel; $new_rel = str_replace( ' ', '-', $old_rel ); if ( $new_rel === $old_rel ) { continue; } $old_abs = $base_dir . '/' . $old_rel; $new_abs = $base_dir . '/' . $new_rel; if ( ! file_exists( $old_abs ) ) { continue; } // Renombrar archivo principal. if ( ! @rename( $old_abs, $new_abs ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors continue; } // Actualizar _wp_attached_file en postmeta. update_post_meta( $row->ID, '_wp_attached_file', $new_rel ); // Actualizar guid en posts. $new_guid = str_replace( ' ', '-', (string) $row->guid ); $wpdb->update( $wpdb->posts, [ 'guid' => $new_guid ], [ 'ID' => $row->ID ] ); clean_post_cache( $row->ID ); // Renombrar miniaturas y actualizar metadata. $meta = wp_get_attachment_metadata( $row->ID ); $dir_old = dirname( $old_abs ); $dir_new = dirname( $new_abs ); if ( ! empty( $meta['sizes'] ) ) { foreach ( $meta['sizes'] as $size => $size_data ) { if ( empty( $size_data['file'] ) || false === strpos( $size_data['file'], ' ' ) ) { continue; } $old_thumb = $dir_old . '/' . $size_data['file']; $new_file = str_replace( ' ', '-', $size_data['file'] ); $new_thumb = $dir_new . '/' . $new_file; if ( file_exists( $old_thumb ) && $old_thumb !== $new_thumb ) { if ( @rename( $old_thumb, $new_thumb ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors $meta['sizes'][ $size ]['file'] = $new_file; } } } wp_update_attachment_metadata( $row->ID, $meta ); } // Actualizar referencias en post_content (URL sin codificar y con %20). $old_url = $base_url . '/' . $old_rel; $new_url = $base_url . '/' . $new_rel; $old_url_encoded = str_replace( ' ', '%20', $old_url ); foreach ( [ $old_url, $old_url_encoded ] as $search_url ) { if ( $search_url === $new_url ) { continue; } $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->posts} SET post_content = REPLACE(post_content, %s, %s) WHERE post_content LIKE %s", $search_url, $new_url, '%' . $wpdb->esc_like( $search_url ) . '%' ) ); } } update_option( 'ykg_space_rename_done', 1, false ); }
Notice: Function _load_textdomain_just_in_time was called incorrectly. Translation loading for the gotmls domain was triggered too early. This is usually an indicator for some code in the plugin or theme running too early. Translations should be loaded at the init action or later. Please see Debugging in WordPress for more information. (This message was added in version 6.7.0.) in /home/u637224327/domains/yakeddgraph.com/public_html/wp-includes/functions.php on line 6131
https://yakeddgraph.com/post-sitemap.xml 2026-04-19T03:22:55+00:00 https://yakeddgraph.com/page-sitemap.xml 2026-03-31T06:38:18+00:00 https://yakeddgraph.com/product-sitemap.xml 2026-03-23T03:09:59+00:00 https://yakeddgraph.com/portfolio-sitemap.xml 2026-04-11T15:25:53+00:00 https://yakeddgraph.com/category-sitemap.xml 2026-04-19T03:22:55+00:00 https://yakeddgraph.com/service-categories-sitemap.xml 2026-04-11T15:25:53+00:00