array( 'a','al','ante','bajo','con','contra','de','del','desde','durante', 'en','entre','hacia','hasta','mediante','para','por','según','sin', 'sobre','tras','el','la','los','las','un','una','unos','unas','lo', 'y','e','ni','o','u','pero','sino','que','como','cuando','donde', 'qué','cuál','quién','cómo','cuándo','dónde','este','esta','estos', 'estas','ese','esa','esos','esas','aquel','aquella','mi','tu','su', 'es','son','ser','estar','haber','tener','hacer', ), 'en' => array( 'a','an','the','and','but','or','nor','for','yet','so','of','in', 'on','at','by','with','about','against','between','into','through', 'during','before','after','above','below','to','from','up','down', 'out','off','over','under','is','are','was','were','be','been', 'being','have','has','had','do','does','did','this','that','these', 'those','as','if','then','than','too','very','can', ), 'fr' => array( 'le','la','les','un','une','des','de','du','d','l','et','ou','mais', 'donc','car','ni','que','qui','quoi','dont','où','à','au','aux', 'avec','sans','sous','sur','dans','par','pour','vers','chez','en', 'entre','contre','depuis','pendant','est','sont','être','avoir', 'ce','cet','cette','ces','mon','ma','mes','ton','ta','tes','son', 'sa','ses', ), 'de' => array( 'der','die','das','den','dem','des','ein','eine','einen','einer', 'eines','einem','und','oder','aber','denn','als','wenn','weil', 'dass','ob','in','an','auf','aus','bei','mit','nach','seit','von', 'zu','für','gegen','ohne','um','durch','über','unter','zwischen', 'ist','sind','war','waren','sein','haben','hat','hatten','werden', 'wird','dies','dieser','diese','dieses', ), 'it' => array( 'il','lo','la','i','gli','le','un','uno','una','di','a','da','in', 'con','su','per','tra','fra','del','dello','della','dei','degli', 'delle','al','allo','alla','ai','agli','alle','dal','dallo','dalla', 'dai','dagli','dalle','nel','nello','nella','nei','negli','nelle', 'sul','sullo','sulla','sui','sugli','sulle','e','o','ma','che', 'è','sono','essere','avere', ), 'pt' => array( 'o','a','os','as','um','uma','uns','umas','de','do','da','dos', 'das','em','no','na','nos','nas','por','para','pelo','pela','pelos', 'pelas','com','sem','sob','sobre','até','desde','durante','entre', 'e','ou','mas','que','se','é','são','ser','estar','ter','haver', 'este','esta','estes','estas','esse','essa','isso','aquele', 'aquela','aquilo', ), 'nl' => array( 'de','het','een','en','of','maar','want','dus','of','dat','die', 'dit','deze','in','op','aan','met','voor','door','om','tot','van', 'naar','bij','tegen','over','onder','tussen','zonder','tijdens', 'is','zijn','was','waren','heb','heeft','hebben','had','hadden', 'word','wordt','worden','werd','werden','mijn','jouw','zijn','haar', ), 'pl' => array( 'i','a','ale','lub','albo','oraz','czy','że','żeby','aby','ponieważ', 'gdyż','bo','więc','jednak','natomiast','w','we','na','o','do','z', 'ze','od','dla','przez','po','pod','nad','przed','między','bez','u', 'jest','są','być','był','była','było','byli','były','mieć','ma', 'mam','masz','ten','ta','to','ci','te','tych','tym','tej','to', ), 'ru' => array( 'и','а','но','или','либо','да','что','чтобы','если','когда','хотя', 'потому','как','же','то','в','во','на','за','под','над','перед', 'при','с','со','от','до','для','через','между','без','о','об','по', 'есть','быть','был','была','было','были','этот','эта','это','эти', 'тот','та','то','те','мой','моя','моё','мои','твой','твоя','его','её','их', ), // Japanese: tokenisation is character-based, not word-based, // and standard slug generation transliterates to romaji or // strips entirely. Stop-word filtering is less applicable. // We keep the list short and target only common particles // that survive transliteration. 'ja' => array( 'の','に','は','を','が','と','で','へ','や','から','まで','など', 'です','ます','する','ある','いる','こと','もの','これ','それ','あれ', ), ); // Allow plugins / themes to extend or override languages. // Use case: someone needs Czech, Turkish, Arabic, etc. They // can hook this filter and add their own list. $cache = apply_filters( 'flink_stop_words', $cache ); return $cache; } /** * Filler phrases removed regardless of language. These are * universal SEO no-value patterns that even non-stop-word * cleaning should drop. * * Order matters: longer phrases first, so "guia paso a paso" * is matched before just "paso a paso". */ private static function filler_phrases() { return array( // ES 'guia paso a paso','guía paso a paso','paso a paso','tutorial completo', 'guia completa','guía completa','para principiantes','desde cero', // EN 'step by step','complete guide','for beginners','from scratch','ultimate guide', // FR 'pas a pas','pas à pas','guide complet','pour debutants','pour débutants', // DE 'schritt fur schritt','schritt für schritt','komplette anleitung','fur anfanger','für anfänger', // IT 'passo passo','guida completa','per principianti', // PT 'passo a passo','guia completo','para iniciantes', ); } /** * Detect language. Priority order: * 1. Polylang (pll_get_post_language) * 2. WPML (wpml_post_language_details) * 3. Heuristic via stop word density * 4. Fallback: 'en' */ public static function detect_language( $title, $post_id = 0 ) { // 1. Polylang if ( $post_id > 0 && function_exists( 'pll_get_post_language' ) ) { $lang = pll_get_post_language( $post_id ); if ( $lang ) return substr( $lang, 0, 2 ); } // 2. WPML if ( $post_id > 0 && function_exists( 'apply_filters' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- 'wpml_post_language_details' is a filter provided by the third-party WPML plugin; we are consumers of it, not the owner. The hook name is defined by WPML and cannot be renamed. $details = apply_filters( 'wpml_post_language_details', null, $post_id ); if ( ! empty( $details['language_code'] ) ) { return substr( $details['language_code'], 0, 2 ); } } // 3. Heuristic: count stop words for each language; the highest // count wins. Ties break in favour of EN (most likely guess // for an English-speaking world default). $tokens = preg_split( '/\s+/', mb_strtolower( $title, 'UTF-8' ) ); $tokens = array_filter( array_map( function( $t ) { // Strip non-letter characters from each token's edges return preg_replace( '/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/u', '', $t ); }, $tokens ) ); if ( empty( $tokens ) ) return 'en'; $best_lang = 'en'; $best_score = 0; foreach ( self::stop_words() as $code => $words ) { $set = array_flip( $words ); $score = 0; foreach ( $tokens as $t ) { if ( isset( $set[ $t ] ) ) $score++; } if ( $score > $best_score ) { $best_score = $score; $best_lang = $code; } } return $best_lang; } /** * Generate an SEO-friendly slug from a title. * * @param string $title Post title. * @param int $post_id Optional. Used for language detection * via Polylang/WPML. Pass 0 if unknown. * @param string $lang Optional. Force a specific language code * (skips auto-detection). 'auto' = detect. * @return string A slug ready to pass to wp_unique_post_slug(). * Always non-empty: even if every word gets * stripped, falls back to sanitize_title($title). */ public static function generate( $title, $post_id = 0, $lang = 'auto' ) { $title = trim( (string) $title ); if ( $title === '' ) return ''; $original = $title; // 1. Detect language if not forced. if ( $lang === 'auto' || $lang === '' ) { $lang = self::detect_language( $title, $post_id ); } // 2. Lowercase for both filler-phrase matching and stop-word // matching. The original case doesn't matter for slugs. $work = mb_strtolower( $title, 'UTF-8' ); // 3. Strip filler phrases (longer first, already ordered). foreach ( self::filler_phrases() as $phrase ) { $work = str_replace( $phrase, ' ', $work ); } // 4. Strip punctuation that may glue stop words to real words // (e.g. "WordPress:" should become "WordPress" so the colon // doesn't survive into the next step's tokenisation). $work = preg_replace( '/[\p{P}\p{S}]+/u', ' ', $work ); // 5. Tokenise on whitespace, drop empties. $tokens = preg_split( '/\s+/u', $work, -1, PREG_SPLIT_NO_EMPTY ); // 6. Drop stop words for the detected language. If the language // has no stop-word list (extension by user), this is a no-op // and we keep all tokens. $stop = self::stop_words(); if ( isset( $stop[ $lang ] ) ) { $set = array_flip( $stop[ $lang ] ); $tokens = array_values( array_filter( $tokens, function( $t ) use ( $set ) { return ! isset( $set[ $t ] ); } ) ); } // 7. Defensive: if filtering left us empty, fall back to the // original title via sanitize_title — never produce empty. if ( empty( $tokens ) ) { return sanitize_title( $original ); } // 8. Truncate at MAX_LENGTH on word boundary. We accumulate // tokens as long as adding the next one (plus its dash) // keeps us under the cap. $slug = ''; foreach ( $tokens as $t ) { $candidate = $slug === '' ? $t : ( $slug . '-' . $t ); // sanitize_title transliterates accents and strips invalid // chars; we apply it per-token to know the real length. $candidate_clean = sanitize_title( $candidate ); if ( strlen( $candidate_clean ) > self::MAX_LENGTH ) { if ( $slug === '' ) { // First token already exceeds cap — keep it, // sanitize_title will be applied at the end. $slug = $t; } break; } $slug = $candidate; } // 9. Final pass: WordPress-standard sanitisation (lowercases, // transliterates accents, replaces spaces with dashes, // strips remaining unsafe chars). $final = sanitize_title( $slug ); // 10. Last resort: if sanitize_title wiped everything (e.g. // pure non-Latin script that doesn't transliterate), // fall back to the original. if ( $final === '' ) { return sanitize_title( $original ); } return $final; } }